Repository navigation
Harden GitHub workflows and SMTP STARTTLS trust - #206
Conversation
maximthomas
left a comment
There was a problem hiding this comment.
praise: The trust-all default is gone, and the GitHub token is narrowed job by job.
EmailClient.configureStartTlsTrustinstalls no socket factory by default, and aGeneralSecurityExceptionnow surfaces asIllegalStateExceptioninstead of being swallowed.- Every workflow starts from
permissions: contents: read. Inrelease.ymlonly the jobs that push getcontents: write/packages: write, each with a comment saying why. - Third-party actions are pinned to commit SHAs with the version as a comment, and
.github/dependabot.ymlkeeps them current.
issue (blocking): With STARTTLS on and no relaxation set, the server certificate's chain is validated but its host name is not, so CodeQL #22 stays open.
openidm-external-email/src/main/java/org/forgerock/openidm/external/email/impl/EmailClient.java:115-117, :126
javax.mail 1.4.7 reads mail.smtp.ssl.checkserveridentity with default false and sets no endpoint identification algorithm, so JSSE checks only the chain, and nothing in the tree sets the flag. A man-in-the-middle with any publicly trusted certificate for a name they control is accepted for host and receives the SMTP AUTH credentials and the mail. The CodeQL predicate (Mail.qll, isInsecureMailPropertyConfig) matches any constant %.socketFactory% key on props, and line 126 still puts one. Only a constant mail.smtp.ssl.checkserveridentity = "true" on the same variable clears it. The predicate is flow-insensitive, so the alert moves from line 96 to line 103 instead of closing. If the check was left off on purpose (relays addressed by IP or localhost), trustedHosts already covers that case.
if (!trustAll && trustedHosts.isEmpty()) {
// validate the chain (JSSE default) and that the certificate was issued for the host
props.put("mail.smtp.ssl.checkserveridentity", "true");
return;
}Pin: in startTlsValidatesTheServerCertificateByDefault, assertThat(props.get("mail.smtp.ssl.checkserveridentity")).isEqualTo("true"); is red at this head. Add a sentence to chap-mail.adoc saying that the certificate must also match host.
issue (blocking): Saving Settings > Email in the Admin UI deletes starttls.trustedHosts and starttls.trustAll from the stored config.
openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/settings/EmailConfigView.js:134-140, openidm-ui/openidm-ui-admin/src/main/resources/templates/admin/settings/EmailConfigTemplate.html:57
save() merges the form into the config with the shallow _.extend(this.data.config, formData). The form's only STARTTLS field is starttls.enable, so formData.starttls is {enable: true} and replaces the whole stored object. Only auth.password is restored by hand. An operator who follows the behaviour-change note and adds trustedHosts for a self-signed relay loses it on the next UI save, a port change for example. After that every send fails the handshake, and nothing points at the UI.
var formData = form2js("emailConfigForm",".", true);
if (_.has(formData, "starttls")) {
// keep the STARTTLS keys the form does not edit (trustedHosts, trustAll)
formData.starttls = _.extend({}, this.data.config.starttls, formData.starttls);
}
_.extend(this.data.config, formData);suggestion (non-blocking): Document that trustedHosts must contain the configured host exactly as written. A host missing from the list is not validated normally: JavaMail rejects it.
openidm-external-email/src/main/java/org/forgerock/openidm/external/email/impl/EmailClient.java:124, openidm-doc/src/main/asciidoc/integrators-guide/chap-mail.adoc:130
Once the list is set, MailSSLSocketFactory skips the trust-store check for every host. SocketFetcher then accepts only a host found in the list (case-sensitive match) and otherwise throws "Server is not trusted: ". The doc ("accepted without validation") and the constant's Javadoc imply that unlisted hosts get normal validation. With host: "mail.example.com" and trustedHosts: ["MAIL.example.com"] (or an IP), sending fails even though the certificate is valid. One caveat, not run: this bundle imports com.sun.mail.util from jakarta.mail 2.0.2. If OSGi wires javax.mail 1.4.7's SocketFetcher to its own copy of that package, its instanceof check fails and an unlisted host is accepted with no check at all.
String host = props.getProperty("mail.smtp.host");
if (!trustAll && !trustedHosts.contains(host)) {
logger.warn("starttls.trustedHosts {} does not contain the SMTP host {}", trustedHosts, host);
}suggestion (non-blocking): No case sets trustAll and trustedHosts together, so no test covers which one takes precedence (EmailClient.java:120-125).
openidm-external-email/src/test/java/org/forgerock/openidm/external/email/impl/EmailClientTest.java:60-71
If the branches are swapped so that trustedHosts is tested first, all four cases stay green. Yet {trustAll: true, trustedHosts: [...]} then no longer accepts every certificate, which contradicts chap-mail.adoc ("trustAll—when true, accepts any server certificate"). I traced this mutant against the four cases by reading; it was not executed.
@Test
public void startTlsTrustAllWinsOverTrustedHosts() throws Exception {
Properties props = sessionProperties(json(object(
field("host", "smtp.example.com"),
field("starttls", object(field("enable", true), field("trustAll", true),
field("trustedHosts", array("mail.internal")))))));
MailSSLSocketFactory sf = (MailSSLSocketFactory) props.get(SOCKET_FACTORY);
assertThat(sf.isTrustAllHosts()).isTrue();
}Pin: this case goes red when the branches are swapped, because isTrustAllHosts() is false there.
fdaf9db to
1fe86c6
Compare
|
@maximthomas all four points are addressed in 1fe86c6 (the branch is also rebased onto the current 1. Host name check. With STARTTLS on and no relaxation set, 2. Admin UI. 3.
4. Precedence. I added
|
|
@maximthomas a correction to point 3 of my previous reply, in 68c17fb. What went wrong. The The same wiring already breaks mail on Fix.
Verified at runtime. I ran a local SMTP server with STARTTLS and a self-signed certificate for
Startup log is clean against the CI pattern. Outside OSGi, on the same javax.mail jar, I also checked |
maximthomas
left a comment
There was a problem hiding this comment.
praise: The round-1 fixes land exactly where the bugs were.
EmailClient.configureStartTlsTrustputs the constantmail.smtp.ssl.checkserveridentity="true"on the default path (EmailClient.java:121), andstartTlsValidatesTheServerCertificateByDefaultpins it.EmailConfigView.savemergesformData.starttlsover the stored object before the shallow_.extend(EmailConfigView.js:137-140), sotrustedHostsandtrustAllsurvive a save.startTlsTrustAllWinsOverTrustedHostspins the precedence, andchap-mail.adocnow documents it together with the exact-match rule fortrustedHosts.
issue (blocking): Excluding jakarta.mail narrows the bundle's import to com.sun.mail.util;version="[1.4,2)", and nothing provides that at runtime, so openidm-external-email no longer resolves.
openidm-external-email/pom.xml:45-53
javax.mail 1.4.7 imports its own com.sun.mail.* packages with the open range version="1.4". Felix resolves bundles one at a time as they start. When 1.4.7 resolves, jakarta.mail 2.0.2 is already resolved and is preferred, so 1.4.7's import is wired to jakarta and its own export is dropped. CI run 37106099485 fails on all 8 build-maven legs after BUILD SUCCESS, with Unable to resolve org.openidentityplatform.openidm.external-email [147]: missing requirement ... (osgi.wiring.package=com.sun.mail.util)(version>=1.4.0)(!(version>=2.0.0)). The bundle is in HealthService.defaultRequiredBundles (openidm-infoservice/src/main/java/org/forgerock/openidm/info/impl/HealthService.java:211), so the instance never logs "OpenIDM ready" and the startup step times out. The same wiring means the SocketFetcher that actually runs is jakarta's, so the premise behind the exclusion does not hold at runtime. socketFactoryComesFromTheJavaMailInUse checks the Maven test classpath, not the OSGi wiring.
<dependency>
<groupId>org.openidentityplatform.openidm</groupId>
<artifactId>openidm-enhanced-config</artifactId>
<version>${project.version}</version>
</dependency>Pin: CI's startup step already turns red on this. The revert above makes it green again, as on master. Drop socketFactoryComesFromTheJavaMailInUse with it.
Or: since EmailClient connects to a single configured host, decide trust for that host in configureStartTlsTrust and pass a plain javax.net.ssl.SSLSocketFactory as mail.smtp.ssl.socketFactory. The bundle then imports no com.sun.mail.util at all. Whichever you choose, check it with a real send on a started instance. 1.4.7's SMTPTransport links MailLogger(Class, String, javax.mail.Session), and jakarta's MailLogger has no such constructor, so nobody has verified that sending works under this wiring, on master either.
suggestion (non-blocking): With the new host check, a relay addressed by IP is rejected even when its certificate carries that IP as a subject alternative name. The documented remedy ("fix the certificate") does not help in that case.
openidm-external-email/src/main/java/org/forgerock/openidm/external/email/impl/EmailClient.java:121, openidm-doc/src/main/asciidoc/integrators-guide/chap-mail.adoc:127
On Java 17+, SocketFetcher.matchCert cannot call sun.security.util.HostnameChecker, because java.base does not export it and the startup scripts add no --add-exports. It falls back to matching dNSName SANs, and the CN only when the certificate has no dNSName SAN. IP SANs are never checked. Measured on Temurin 17.0.11 and JDK 26 with both mail jars: a certificate with SAN ip:127.0.0.1 gives 127.0.0.1 -> false, and adding --add-exports java.base/sun.security.util=ALL-UNNAMED makes it true. Your behaviour-change note covers relays whose certificate does not match. This case has a matching certificate and still fails. The only ways out are a DNS host name, or trustedHosts / trustAll, which skip validation entirely.
The SMTP server certificate is validated against the JVM trust store and must be issued for the configured `host`.
On Java 17 and later the host name is matched only against the certificate's DNS subject alternative names (or, if it has none, its CN), so `host` must be a DNS name the certificate carries; a relay addressed by IP address is rejected even with an IP-address subject alternative name.suggestion (non-blocking): Nothing tests the new starttls merge in EmailConfigView.save.
openidm-ui/openidm-ui-admin/src/main/js/org/forgerock/openidm/ui/admin/settings/EmailConfigView.js:137-140, openidm-ui/openidm-ui-admin/src/test/qunit/org/forgerock/openidm/ui/admin/settings/EmailConfigViewTest.js
EmailConfigViewTest.js declares only QUnit.module(...) and has no tests. The e2e workflow.spec.mjs opens #settings/email but never saves. If the merge is deleted, or moved after _.extend(this.data.config, formData), every QUnit and e2e case stays green while a save strips trustedHosts / trustAll again.
QUnit.test("save keeps the STARTTLS keys the form does not edit", function (assert) {
var saved, stub = sinon.stub(ConfigDelegate, "updateEntity", function (id, config) {
saved = config; return $.Deferred();
});
$("#qunit-fixture").html('<input type="checkbox" id="emailToggle" checked>' +
'<form id="emailConfigForm"><input type="checkbox" name="starttls.enable" value="true" checked></form>');
EmailConfigView.$el = $("#qunit-fixture");
EmailConfigView.model = { externalEmailExists: true };
EmailConfigView.data = { config: { host: "smtp.example.com",
starttls: { enable: true, trustedHosts: ["smtp.internal"], trustAll: false } } };
EmailConfigView.save({ preventDefault: $.noop });
stub.restore();
assert.deepEqual(saved.starttls.trustedHosts, ["smtp.internal"]);
assert.strictEqual(saved.starttls.trustAll, false);
});Pin: with "jquery", "sinon" and ConfigDelegate added to the module's deps, deleting the merge fails both assertions. Not run.
suggestion (non-blocking): withoutStartTlsNoSocketFactoryIsConfigured also passes at the base commit, and it does not pin that trust settings apply only under STARTTLS.
openidm-external-email/src/test/java/org/forgerock/openidm/external/email/impl/EmailClientTest.java:96-102
At the base, too, the factory was set only inside if (startTLS). If configureStartTlsTrust(starttlsConfig) is moved out of that branch, this case stays green, because with {host} the mutant only adds checkserveridentity, which the case does not assert. No case covers {enable: false, trustAll: true}.
@Test
public void trustSettingsApplyOnlyWithStartTls() throws Exception {
Properties props = sessionProperties(json(object(
field("host", "smtp.example.com"),
field("starttls", object(field("enable", false), field("trustAll", true))))));
assertThat(props.get(SOCKET_FACTORY)).isNull();
assertThat(props.get(CHECK_SERVER_IDENTITY)).isNull();
}suggestion (non-blocking): The new warning for a host missing from trustedHosts is not pinned. Deleting it, or inverting contains(), keeps all six EmailClientTest cases green.
openidm-external-email/src/main/java/org/forgerock/openidm/external/email/impl/EmailClient.java:124-128
The only trustedHosts case uses a host that is in the list, and no case captures log output. The logger is private static final.
Pin: only if the test classpath binds slf4j to logback-classic. Attach a ListAppender to LoggerFactory.getLogger(EmailClient.class), build with host smtp.other and trustedHosts: ["smtp.example.com"], and assert exactly one WARN that mentions smtp.other, then none when the host is listed. Otherwise leave it.
suggestion (non-blocking): STARTTLS is still opportunistic: mail.smtp.starttls.required is never set. A follow-up, not for this PR.
openidm-external-email/src/main/java/org/forgerock/openidm/external/email/impl/EmailClient.java:101
When the server does not advertise STARTTLS, 1.4.7's SMTPTransport throws only if .starttls.required is true. Otherwise it goes straight on to AUTH. An on-path attacker who strips STARTTLS from the EHLO reply gets the credentials and the mail in clear, and the new certificate checks never run. This predates the PR and is documented at chap-mail.adoc:125, but it is the next step toward the PR's on-path-attacker goal.
props.put("mail.smtp.starttls.required",
String.valueOf(starttlsConfig.get("required").defaultTo(false).asBoolean()));- Set an explicit read-only GITHUB_TOKEN permissions block on the build, deploy and release workflows, elevating only the jobs that need it (wiki/docs push and release tag: contents:write; ghcr push: packages:write) - Pin the third-party docker/* and softprops/action-gh-release actions to commit SHAs - EmailClient: stop trusting every SMTP server certificate over STARTTLS; validation is now the default, with opt-in starttls.trustedHosts / starttls.trustAll settings (documented) Resolves CodeQL alerts #711-#716, #718-#738, #920, #921 (actions) and OpenIdentityPlatform#22 (java/insecure-smtp-ssl).
Keeps the commit-hash-pinned third-party actions in .github/workflows up to date: Dependabot bumps the SHA and the trailing version comment together, grouped into one weekly PR. Ported from OpenIdentityPlatform/OpenIG#170.
- Validate the SMTP certificate's host name by default (mail.smtp.ssl.checkserveridentity=true); JavaMail 1.4.7 leaves it off - Exclude jakarta.mail from the email bundle's classpath: compiling against its com.sun.mail.util made the bundle import MailSSLSocketFactory from jakarta.mail, which javax.mail's SocketFetcher does not recognise, so starttls.trustedHosts trusted every host - Warn when starttls.trustedHosts does not contain the SMTP host - Admin UI: keep starttls.trustedHosts/trustAll when saving the Email form - Tests: host check, trustAll precedence, socket factory origin - Docs: host match, exact trustedHosts entry, trustAll precedence
The com.sun.mail.util [1.4,2) import from the previous commit left openidm-external-email unresolved, so OpenIDM never reached "ready" in CI: javax.mail 1.4.7 imports its own com.sun.mail.* packages with no upper bound, Felix wires them to jakarta.mail 2.0.2 and drops the 1.4.7 exports. The same wiring already broke mail on master, where MimeMessage fails with NoSuchMethodError on com.sun.mail.util.PropUtil. - Embed javax.mail 1.4.7 in the bundle so Session, SMTPTransport, SocketFetcher and MailSSLSocketFactory come from one jar, and point the context class loader at the bundle while JavaMail loads its providers. - Set mail.smtp.ssl.protocols to the JVM defaults when STARTTLS is on. Without it JavaMail 1.4.7 enables only TLSv1, which current JDKs disable, so no STARTTLS handshake could succeed.
- chap-mail.adoc: on Java 17+ the host is matched only against DNS SANs (or the CN), so a relay addressed by IP is rejected even with an IP SAN - EmailClientTest: replace withoutStartTlsNoSocketFactoryIsConfigured with trustSettingsApplyOnlyWithStartTls, which fails if the trust settings are applied outside STARTTLS - EmailConfigViewTest: pin that saving Settings > Email keeps starttls.trustedHosts and starttls.trustAll
68c17fb to
a997a14
Compare
|
@maximthomas round 2 is addressed in a997a14 (the branch is also rebased onto the current 1. Bundle resolution (blocking). This one describes 1fe86c6, not the head the review is attached to. CI run 37106099485 built 1fe86c6, and that head's 2. IP SAN on Java 17+. Confirmed. The startup scripts add no 3. Admin UI merge. Added 4. STARTTLS-only trust. 5. Warning pin. Skipped, per your condition. The module's test classpath has no SLF4J provider (the run logs "No SLF4J providers were found"), so there is no logback 6. |
maximthomas
left a comment
There was a problem hiding this comment.
praise: Embedding javax.mail fixes the unresolvable bundle where the problem starts, and the trust code now runs on one consistent JavaMail.
openidm-external-email/pom.xmlembedsmail-1.4.7.jar(Embed-Dependency) and imports nocom.sun.mail.*, soSession,SocketFetcherandMailSSLSocketFactorycome from one jar. Run 37281020442 is green on all 8 build-maven legs, and the bundle is a required bundle inHealthService.EmailClient.defaultTlsProtocols()(EmailClient.java:122) setsmail.smtp.ssl.protocols, so STARTTLS no longer falls back to 1.4.7's TLSv1-only default;startTlsUsesTheJvmDefaultProtocolspins it.trustSettingsApplyOnlyWithStartTlsreplaces a case that was green at the base and now fails if the trust settings are applied outsideif (startTLS).
Summary
Closes 30 of the 31 open medium CodeQL alerts: the 29 GitHub Actions findings and
java/insecure-smtp-ssl#22. The remaining one (#4,ResourceServletredirect) touches a file that #202 is already changing and will follow once that PR lands.GitHub Actions
actions/missing-workflow-permissions(#711–#716, #920, #921) — every workflow now starts frompermissions: contents: read; only the jobs that actually use the token get more, each with an inline comment saying why:build.yml(all jobs)contents: readregistry:2service need nothing elsedeploy.yml/deploy-mavencontents: writegithub.token(the doc-site push uses a PAT)release.yml/release-mavencontents: writerelease:preparepushes the release tag,action-gh-releasecreates the release, docs go to the wikirelease.yml/release-docker*contents: read,packages: writeGITHUB_TOKENactions/unpinned-tag(#718–#738) — the six third-party actions are pinned to commit SHAs, with the resolved version kept as a comment:docker/metadata-actionv6.2.0 ·docker/setup-qemu-actionv4.4.0 ·docker/setup-buildx-actionv4.4.1 ·docker/build-push-actionv7.4.0 ·docker/login-actionv4.6.0 ·softprops/action-gh-releasev3.0.3actions/*andgithub/*are not covered by the rule and stay on major tags.Second commit adds
.github/dependabot.yml(ported from OpenIdentityPlatform/OpenIG#170) with thegithub-actionsecosystem, grouped into one weekly PR, so the pinned SHAs stay current. All six pins above were verified against the actions' latest releases at the time of writing and are already up to date.#22 —
EmailClienttrusted every SMTP certificate over STARTTLSThe "temporary hack to avoid cert check" installed a
MailSSLSocketFactorywithsetTrustAllHosts(true)wheneverstarttls.enablewas set, so the TLS upgrade gave no protection against an on-path attacker. By default the certificate must now chain to the JVM trust store and be issued for the configuredhost(mail.smtp.ssl.checkserveridentity=true, which JavaMail 1.4.7 leaves off). Two optional settings relax it (documented in the integrator's guide):Once
trustedHostsis set, JavaMail accepts only ahostfound in the list (case-sensitive) and rejects any other, soEmailClientlogs a warning when the configuredhostis missing from it.javax.mail is embedded in the bundle (review rounds 1–2).
bundle/ships bothjavax.mail:mail:1.4.7andcom.sun.mail:jakarta.mail:2.0.2, and both containcom.sun.mail.*. The javax.mail bundle imports its owncom.sun.mail.*packages withversion="1.4"and no upper bound, so Felix wires them to jakarta.mail 2.0.2 and drops the 1.4.7 exports. This already breaks mail onmaster:MimeMessagefails withNoSuchMethodErroroncom.sun.mail.util.PropUtil, andexternal/email?_action=sendreturns 500. Before this fix,MailSSLSocketFactorycame from jakarta.mail, so javax.mail'sSocketFetcherdid not recognise it, and a non-emptytrustedHostswould have trusted every host. Nowopenidm-external-emailembeds javax.mail 1.4.7 (Embed-Dependency) and imports neitherjavax.mailnorcom.sun.mail, soSession,SMTPTransport,SocketFetcherandMailSSLSocketFactoryall come from one jar. While JavaMail looks up providers,EmailClientpoints the context class loader at the bundle. jakarta.mail is excluded from this module's classpath and still ships inbundle/through the other modules.STARTTLS protocols: with no
mail.smtp.ssl.protocolsset, JavaMail 1.4.7 enables onlyTLSv1on the STARTTLS socket, and current JDKs disable it, so no handshake could succeed.EmailClientnow sets the property to the JVM's default protocols.Admin UI: saving Settings > Email now keeps
starttls.trustedHosts/starttls.trustAll, which the form does not edit. Before, the shallow merge replaced the storedstarttlsobject with{enable}.Behaviour change: deployments that use STARTTLS against an SMTP server with a self-signed or otherwise untrusted certificate, or with a certificate that does not match
host, will fail to send mail until they either fix the certificate / trust store or settrustedHosts/trustAll. A relay addressed by IP is rejected on Java 17+ even when its certificate carries that IP as a subject alternative name: JavaMail 1.4.7 cannot reachsun.security.util.HostnameCheckerthere and matches only DNS names (or the CN), so such a relay needs a DNShostortrustedHosts(documented inchap-mail.adoc).Out of scope: STARTTLS stays opportunistic, because
mail.smtp.starttls.requiredis never set. That is tracked in #246.Test plan
EmailClientTest(7): default → no custom socket factory and host check on;trustAll→ trust-all factory;trustedHosts→ limited factory, no host check;trustAllwins overtrustedHosts; STARTTLS uses the JVM's default TLS protocols;MailSSLSocketFactorycomes from the same jar asjavax.mail.Session; STARTTLS off withtrustAllset → nothing configured. The host-check, protocols and socket-factory-origin cases fail without the fixes, and the STARTTLS-off case fails if the trust settings are applied outsideif (startTLS)openidm-external-emailsuite green (12/12)mail-1.4.7.jar(Bundle-ClassPath: .,mail-1.4.7.jar) and imports neitherjavax.mailnorcom.sun.maillocalhost: startup reaches "ready" with a clean log;trustedHosts: ["localhost"]delivers over TLS; a host missing fromtrustedHostsis rejected ("Server is not trusted") and the warning is logged; default settings reject the certificate. The same install with themasterbundle returns 500 (NoSuchMethodError)trustAlldelivers; a case-mismatchedtrustedHostsentry is rejected; with the test CA trusted,localhostdelivers and127.0.0.1is rejected by the host name checksave keeps the STARTTLS keys the form does not edit(EmailConfigViewTest.js): the Admin UI build passes (94/94), and the case fails without thestarttlsmerge inEmailConfigView.save()contents: write/packages: writeare sufficient (the token had full default permissions before)