diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000000..98caa0cf0d --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +# The contents of this file are subject to the terms of the Common Development and +# Distribution License (the License). You may not use this file except in compliance with the +# License. +# +# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the +# specific language governing permission and limitations under the License. +# +# When distributing Covered Software, include this CDDL Header Notice in each file and include +# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL +# Header, with the fields enclosed by brackets [] replaced by your own identifying +# information: "Portions copyright [year] [name of copyright owner]". +# +# Copyright 2026 3A Systems, LLC. +version: 2 +updates: + # Keeps the SHA-pinned third-party actions in .github/workflows up to date: Dependabot + # bumps the commit hash and the trailing "# vX.Y.Z" version comment together. + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + groups: + # One pull request per week for all action updates instead of one per action. + github-actions: + patterns: ["*"] + labels: + - "ci" + - "dependencies" diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index bfda1ce5fd..e5f2a6e91e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -20,6 +20,8 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true +permissions: + contents: read jobs: build-maven: runs-on: ${{ matrix.os }} @@ -299,20 +301,20 @@ jobs: echo "release_version=$git_version_last" >> $GITHUB_ENV - name: Docker meta id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | localhost:5000/${{ github.repository }} tags: | type=raw,value=${{ env.release_version }} - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 with: driver-opts: network=host - name: Build image (default) - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 continue-on-error: true with: context: . @@ -347,7 +349,7 @@ jobs: echo "release_version=$git_version_last" >> $GITHUB_ENV - name: Docker meta id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | localhost:5000/${{ github.repository }} @@ -355,14 +357,14 @@ jobs: type=raw,value=alpine type=raw,value=${{ env.release_version }}-alpine - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 with: driver-opts: network=host - name: Build image continue-on-error: true - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ./Dockerfile-alpine diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index bddaecbf0a..2a073ae35a 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -22,11 +22,15 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.event.workflow_run.head_branch }} cancel-in-progress: false +permissions: + contents: read jobs: deploy-maven: if: ${{ github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event=='push' }} runs-on: 'ubuntu-latest' + permissions: + contents: write # the docs push to the repository wiki uses github.token steps: - name: Print github context env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 23c5e681ab..fb27ba8b46 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -27,9 +27,13 @@ on: concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: false +permissions: + contents: read jobs: release-maven: runs-on: 'ubuntu-latest' + permissions: + contents: write # release:prepare pushes the release tag, action-gh-release creates the release, docs go to the wiki steps: - name: Print github context env: @@ -75,7 +79,7 @@ jobs: if: ${{ env.MAVEN_USERNAME!='' && env.MAVEN_PASSWORD!='' }} run: mvn --batch-mode -Darguments="-Dgpg.passphrase=${{ secrets.GPG_PASSPHRASE }}" -DsignTag=true -DtagNameFormat="${{ github.event.inputs.releaseVersion }}" -DreleaseVersion=${{ github.event.inputs.releaseVersion }} -DdevelopmentVersion=${{ github.event.inputs.developmentVersion }} release:prepare release:perform --file pom.xml - name: Release on GitHub - uses: softprops/action-gh-release@v3 + uses: softprops/action-gh-release@efb35369e0ad2afab669f228072c1b0d510eae64 # v3.0.3 with: name: ${{ github.event.inputs.releaseVersion }} tag_name: ${{ github.event.inputs.releaseVersion }} @@ -128,6 +132,9 @@ jobs: release-docker: name: Docker release runs-on: 'ubuntu-latest' + permissions: + contents: read + packages: write # push to ghcr.io with GITHUB_TOKEN needs: - release-maven steps: @@ -138,7 +145,7 @@ jobs: submodules: recursive - name: Docker meta id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | ${{ github.repository }} @@ -147,22 +154,22 @@ jobs: type=raw,value=latest type=raw,value=${{ github.event.inputs.releaseVersion }} - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to DockerHub - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 continue-on-error: true with: context: . @@ -176,6 +183,9 @@ jobs: release-docker-alpine: name: Docker release runs-on: 'ubuntu-latest' + permissions: + contents: read + packages: write # push to ghcr.io with GITHUB_TOKEN needs: - release-maven steps: @@ -186,7 +196,7 @@ jobs: submodules: recursive - name: Docker meta id: meta - uses: docker/metadata-action@v6 + uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0 with: images: | ${{ github.repository }} @@ -195,23 +205,23 @@ jobs: type=raw,value=alpine type=raw,value=${{ github.event.inputs.releaseVersion }}-alpine - name: Set up QEMU - uses: docker/setup-qemu-action@v4 + uses: docker/setup-qemu-action@99012661954931238ded8c8b007157a8430204e1 # v4.4.0 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v4 + uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1 - name: Login to DockerHub - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: username: ${{ secrets.DOCKER_USERNAME }} password: ${{ secrets.DOCKER_PASSWORD }} - name: Login to GHCR - uses: docker/login-action@v4 + uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0 with: registry: ghcr.io username: ${{ github.repository_owner }} password: ${{ secrets.GITHUB_TOKEN }} - name: Build and push image continue-on-error: true - uses: docker/build-push-action@v7 + uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0 with: context: . file: ./Dockerfile-alpine diff --git a/openidm-doc/src/main/asciidoc/integrators-guide/chap-mail.adoc b/openidm-doc/src/main/asciidoc/integrators-guide/chap-mail.adoc index 1abff680ce..ec12066718 100644 --- a/openidm-doc/src/main/asciidoc/integrators-guide/chap-mail.adoc +++ b/openidm-doc/src/main/asciidoc/integrators-guide/chap-mail.adoc @@ -12,7 +12,7 @@ information: "Portions copyright [year] [name of copyright owner]". Copyright 2017 ForgeRock AS. - Portions Copyright 2024 3A Systems LLC. + Portions Copyright 2024-2026 3A Systems LLC. //// :figure-caption!: @@ -123,6 +123,13 @@ If `"enable" : false`, you can leave the entries for `"username"` and `"password `starttls`:: If `"enable" : true`, enables the use of the STARTTLS command (if supported by the server) to switch the connection to a TLS-protected connection before issuing any login commands. If the server does not support STARTTLS, the connection continues without the use of TLS. ++ +The SMTP server certificate is validated against the JVM trust store. Two optional settings relax that: ++ + +* `trustedHosts`—a list of SMTP host names whose certificate is accepted without validation, for example `"trustedHosts" : [ "smtp.internal.example.com" ]`. + +* `trustAll`—when `true`, accepts any server certificate. This disables protection against man-in-the-middle attacks; use it only in development environments. `from`:: (Optional) Specifies a default `From:` address, that users see when they receive emails from OpenIDM. diff --git a/openidm-external-email/src/main/java/org/forgerock/openidm/external/email/impl/EmailClient.java b/openidm-external-email/src/main/java/org/forgerock/openidm/external/email/impl/EmailClient.java index 31c02d763f..15765e7e4f 100644 --- a/openidm-external-email/src/main/java/org/forgerock/openidm/external/email/impl/EmailClient.java +++ b/openidm-external-email/src/main/java/org/forgerock/openidm/external/email/impl/EmailClient.java @@ -20,6 +20,8 @@ * with the fields enclosed by brackets [] replaced by * your own identifying information: * "Portions Copyrighted [year] [name of copyright owner]" + * + * Portions Copyright 2026 3A Systems, LLC. */ package org.forgerock.openidm.external.email.impl; @@ -27,7 +29,12 @@ import com.sun.mail.util.MailSSLSocketFactory; import org.forgerock.json.JsonValue; import org.forgerock.json.resource.BadRequestException; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.security.GeneralSecurityException; +import java.util.Collections; +import java.util.List; import java.util.Properties; import javax.mail.Message; import javax.mail.MessagingException; @@ -42,6 +49,8 @@ */ public class EmailClient { + private static final Logger logger = LoggerFactory.getLogger(EmailClient.class); + private static final String DEFAULT_HOST = "localhost"; private static final String DEFAULT_PORT = "25"; private String username = null; @@ -60,6 +69,10 @@ public class EmailClient { public static final String CONFIG_MAIL_SMTP_AUTH_USERNAME = "username"; public static final String CONFIG_MAIL_SMTP_STARTTLS = "starttls"; public static final String CONFIG_MAIL_SMTP_STARTTLS_ENABLE = "enable"; + /** Opt-in: accept any server certificate over STARTTLS. Never use outside development. */ + public static final String CONFIG_MAIL_SMTP_STARTTLS_TRUST_ALL = "trustAll"; + /** Optional list of SMTP hosts whose certificate is accepted without validation. */ + public static final String CONFIG_MAIL_SMTP_STARTTLS_TRUSTED_HOSTS = "trustedHosts"; public static final String CONFIG_MAIL_FROM = "from"; public static final String CONFIG_MAIL_DEBUG = "debug"; @@ -83,19 +96,39 @@ public EmailClient(JsonValue config) throws RuntimeException { boolean startTLS = starttlsConfig.get(CONFIG_MAIL_SMTP_STARTTLS_ENABLE).defaultTo(false).asBoolean(); if (startTLS) { props.put("mail.smtp.starttls.enable", String.valueOf(startTLS)); - // temporary hack to avoid cert check - try { - MailSSLSocketFactory sf = new MailSSLSocketFactory(); - sf.setTrustAllHosts(true); - props.put("mail.smtp.ssl.socketFactory", sf); - } catch (Exception e) { - } + configureStartTlsTrust(starttlsConfig); } fromAddr = config.get(CONFIG_MAIL_FROM).asString(); session = Session.getInstance(props); } + /** + * By default the server certificate is validated against the JVM trust store. A custom + * socket factory is installed only when the configuration explicitly relaxes that, either + * for a list of {@code trustedHosts} or, for development only, for all hosts. + */ + private void configureStartTlsTrust(JsonValue starttlsConfig) { + boolean trustAll = starttlsConfig.get(CONFIG_MAIL_SMTP_STARTTLS_TRUST_ALL).defaultTo(false).asBoolean(); + List trustedHosts = starttlsConfig.get(CONFIG_MAIL_SMTP_STARTTLS_TRUSTED_HOSTS) + .defaultTo(Collections.emptyList()).asList(String.class); + if (!trustAll && trustedHosts.isEmpty()) { + return; + } + try { + MailSSLSocketFactory sf = new MailSSLSocketFactory(); + if (trustAll) { + logger.warn("SMTP STARTTLS certificate validation is disabled (starttls.trustAll=true)"); + sf.setTrustAllHosts(trustAll); + } else { + sf.setTrustedHosts(trustedHosts.toArray(new String[0])); + } + props.put("mail.smtp.ssl.socketFactory", sf); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("Unable to configure the SMTP STARTTLS socket factory", e); + } + } + /** * Send the email according to the parameters in params: * diff --git a/openidm-external-email/src/test/java/org/forgerock/openidm/external/email/impl/EmailClientTest.java b/openidm-external-email/src/test/java/org/forgerock/openidm/external/email/impl/EmailClientTest.java new file mode 100644 index 0000000000..7b721a5d74 --- /dev/null +++ b/openidm-external-email/src/test/java/org/forgerock/openidm/external/email/impl/EmailClientTest.java @@ -0,0 +1,88 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ +package org.forgerock.openidm.external.email.impl; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.forgerock.json.JsonValue.array; +import static org.forgerock.json.JsonValue.field; +import static org.forgerock.json.JsonValue.json; +import static org.forgerock.json.JsonValue.object; + +import java.lang.reflect.Field; +import java.util.Properties; + +import javax.mail.Session; + +import com.sun.mail.util.MailSSLSocketFactory; +import org.forgerock.json.JsonValue; +import org.testng.annotations.Test; + +/** + * Tests for the STARTTLS trust settings of {@link EmailClient}. + */ +public class EmailClientTest { + + private static final String SOCKET_FACTORY = "mail.smtp.ssl.socketFactory"; + + @Test + public void startTlsValidatesTheServerCertificateByDefault() throws Exception { + Properties props = sessionProperties(json(object( + field("host", "smtp.example.com"), + field("starttls", object(field("enable", true)))))); + + assertThat(props.get("mail.smtp.starttls.enable")).isEqualTo("true"); + assertThat(props.get(SOCKET_FACTORY)).as("no custom trust: JSSE validation applies").isNull(); + } + + @Test + public void startTlsTrustAllIsOptIn() throws Exception { + Properties props = sessionProperties(json(object( + field("host", "smtp.example.com"), + field("starttls", object(field("enable", true), field("trustAll", true)))))); + + MailSSLSocketFactory sf = (MailSSLSocketFactory) props.get(SOCKET_FACTORY); + assertThat(sf).isNotNull(); + assertThat(sf.isTrustAllHosts()).isTrue(); + } + + @Test + public void startTlsTrustedHostsAreLimitedToTheConfiguredList() throws Exception { + Properties props = sessionProperties(json(object( + field("host", "smtp.example.com"), + field("starttls", object(field("enable", true), + field("trustedHosts", array("smtp.example.com", "mail.internal"))))))); + + MailSSLSocketFactory sf = (MailSSLSocketFactory) props.get(SOCKET_FACTORY); + assertThat(sf).isNotNull(); + assertThat(sf.isTrustAllHosts()).isFalse(); + assertThat(sf.getTrustedHosts()).containsExactly("smtp.example.com", "mail.internal"); + } + + @Test + public void withoutStartTlsNoSocketFactoryIsConfigured() throws Exception { + Properties props = sessionProperties(json(object(field("host", "smtp.example.com")))); + + assertThat(props.get("mail.smtp.starttls.enable")).isNull(); + assertThat(props.get(SOCKET_FACTORY)).isNull(); + } + + private static Properties sessionProperties(JsonValue config) throws Exception { + EmailClient client = new EmailClient(config); + Field session = EmailClient.class.getDeclaredField("session"); + session.setAccessible(true); + return ((Session) session.get(client)).getProperties(); + } +}