This is an automated email from the ASF dual-hosted git repository. chibenwa pushed a commit to branch master in repository https://gitbox.apache.org/repos/asf/james-jdkim.git
commit a74fba103e88d840033408f6cf7f7a4e0cdf3836 Author: Benoit TELLIER <[email protected]> AuthorDate: Sun Nov 1 19:37:21 2026 +0000 [FIX] DKIMVerifier: do not let unsigned extra once-only fields pass A signature listing from once in h= covers only the bottom-most From: field. Anyone holding a signed message could add another From: above it, or another Reply-To:, Subject:, To:..., and still get dkim=pass, while mail clients display the added one (RFC 6376 section 8.15). VerifierOptions gains withUnsignedDuplicateFieldPolicy (ACCEPT, FLAG, REJECT), defaulting to the james.dkim.unsigned-duplicate-field.policy system property, or REJECT, and withSingleInstanceFields, defaulting to the fields RFC 5322 section 3.6 allows once. When a message holds more than one instance of such a field listed in h=, and more than h= lists, REJECT fails the signature and FLAG keeps the PASS with a warning in its Result. Oversigning (h=from:from) keeps working. --- docs/modules/ROOT/pages/usage.adoc | 18 +++ .../java/org/apache/james/jdkim/DKIMVerifier.java | 41 ++++++- .../java/org/apache/james/jdkim/api/Result.java | 26 ++++- .../jdkim/api/UnsignedDuplicateFieldPolicy.java | 41 +++++++ .../apache/james/jdkim/api/VerifierOptions.java | 101 +++++++++++++++++ .../james/jdkim/impl/CompoundBodyHasher.java | 12 ++ .../test/java/org/apache/james/jdkim/DKIMTest.java | 126 +++++++++++++++++++++ .../james/jdkim/DKIMVerifierOptionsTest.java | 44 +++++++ 8 files changed, 407 insertions(+), 2 deletions(-) diff --git a/docs/modules/ROOT/pages/usage.adoc b/docs/modules/ROOT/pages/usage.adoc index 2580b73..452eb5a 100644 --- a/docs/modules/ROOT/pages/usage.adoc +++ b/docs/modules/ROOT/pages/usage.adoc @@ -217,6 +217,24 @@ get a `PERMERROR` result ("Too many DKIM-Signature fields") without any lookup. Defaults to the `james.dkim.max-signature.count` system property, or 32 when it is unset (e.g. `-Djames.dkim.max-signature.count=32`); must be strictly positive. +`withUnsignedDuplicateFieldPolicy(UnsignedDuplicateFieldPolicy)`:: +What happens to a signature when the message holds more instances of a +single-instance field listed in its `h=` than `h=` lists, and more than one in +total: the extra +instance is not signed, yet mail clients usually display the top-most one +(https://datatracker.ietf.org/doc/html/rfc6376#section-8.15[RFC 6376 section 8.15]). +`REJECT` fails the signature, `FLAG` keeps the `PASS` but sets `Result#getWarning()` +(also written in the `reason` of `getHeaderTextWithReason()`), `ACCEPT` ignores it. +Defaults to the `james.dkim.unsigned-duplicate-field.policy` system property, or +`REJECT` when it is unset. Signers avoid the issue by listing the field once more +than it appears (`h=from:from`). + +`withSingleInstanceFields(Collection<String>)`:: +The field names checked by the policy above, case-insensitive. Defaults to the +fields https://datatracker.ietf.org/doc/html/rfc5322#section-3.6[RFC 5322 section 3.6] +allows once: `from`, `sender`, `reply-to`, `to`, `cc`, `bcc`, `subject`, `date`, +`message-id`, `in-reply-to`, `references`. + `withDnsResolver(Resolver)`:: A dnsjava `Resolver` to query instead of the system default -- useful to point at a specific resolver or to shorten timeouts. diff --git a/main/src/main/java/org/apache/james/jdkim/DKIMVerifier.java b/main/src/main/java/org/apache/james/jdkim/DKIMVerifier.java index f15e289..3c501c3 100644 --- a/main/src/main/java/org/apache/james/jdkim/DKIMVerifier.java +++ b/main/src/main/java/org/apache/james/jdkim/DKIMVerifier.java @@ -27,6 +27,7 @@ import org.apache.james.jdkim.api.PublicKeyRecord; import org.apache.james.jdkim.api.PublicKeyRecordRetriever; import org.apache.james.jdkim.api.Result; import org.apache.james.jdkim.api.SignatureRecord; +import org.apache.james.jdkim.api.UnsignedDuplicateFieldPolicy; import org.apache.james.jdkim.api.VerifierOptions; import org.apache.james.jdkim.exceptions.CompositeFailException; import org.apache.james.jdkim.exceptions.FailException; @@ -50,11 +51,13 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; import java.util.Arrays; +import java.util.HashMap; import java.util.Iterator; import java.util.LinkedHashSet; import java.util.LinkedList; import java.util.List; import java.util.Locale; +import java.util.Map; import java.util.Set; public class DKIMVerifier { @@ -303,6 +306,15 @@ public class DKIMVerifier { } } + String unsignedInstance = unsignedSingleInstanceField(messageHeaders, signatureRecord); + if (unsignedInstance != null) { + if (options.getUnsignedDuplicateFieldPolicy() == UnsignedDuplicateFieldPolicy.REJECT) { + throw new PermFailException(unsignedInstance, signatureRecord); + } else if (options.getUnsignedDuplicateFieldPolicy() == UnsignedDuplicateFieldPolicy.FLAG) { + job.setWarning(unsignedInstance); + } + } + // TODO here we could check more parameters for // validation before running a network operation like the // dns lookup. @@ -343,6 +355,33 @@ public class DKIMVerifier { return new CompoundBodyHasher(jobs); } + /** + * RFC 6376 section 8.15: a single-instance field listed in h=, present more times than h= lists it and + * more than once, has an unsigned instance that mail clients may display instead of the signed one. + * + * @return a description of the first such field, or null if there is none + */ + private String unsignedSingleInstanceField(Headers messageHeaders, SignatureRecord signatureRecord) { + if (options.getUnsignedDuplicateFieldPolicy() == UnsignedDuplicateFieldPolicy.ACCEPT) { + return null; + } + Map<String, Integer> signedCounts = new HashMap<>(); + for (CharSequence header : signatureRecord.getHeaders()) { + signedCounts.merge(header.toString().trim().toLowerCase(Locale.US), 1, Integer::sum); + } + for (String name : options.getSingleInstanceFields()) { + List<String> instances = messageHeaders.getFields(name); + int present = instances == null ? 0 : instances.size(); + int signed = signedCounts.getOrDefault(name, 0); + // a field h= does not list is not vouched for by the signature; From: is always listed. + if (signed > 0 && present > 1 && present > signed) { + return "the message has " + present + " " + name + " fields and h= signs only " + signed + + " (RFC 6376 section 8.15)"; + } + } + return null; + } + /** * The field being verified is hashed implicitly, as the last field (RFC 6376 * section 3.5): like the signer, which did not have it yet, a DKIM-Signature @@ -452,7 +491,7 @@ public class DKIMVerifier { "Computed bodyhash is different from the expected one", bhj.getSignatureRecord())); } else { verifiedSignatures.add(bhj.getSignatureRecord()); - result.add(new Result(bhj.getSignatureRecord())); + result.add(new Result(bhj.getSignatureRecord(), job.getWarning())); continue; } } diff --git a/main/src/main/java/org/apache/james/jdkim/api/Result.java b/main/src/main/java/org/apache/james/jdkim/api/Result.java index bd6cc4a..b57465f 100644 --- a/main/src/main/java/org/apache/james/jdkim/api/Result.java +++ b/main/src/main/java/org/apache/james/jdkim/api/Result.java @@ -31,6 +31,7 @@ public class Result { private final String dkimRawField; private final SignatureRecord record; private final Type type; + private final String warning; /** * Result type @@ -60,6 +61,7 @@ public class Result { this.dkimRawField = dkimRawField; this.record = record; this.type = type; + this.warning = null; } /** @@ -68,10 +70,21 @@ public class Result { * @param record SignatureRecord */ public Result(SignatureRecord record) { + this(record, null); + } + + /** + * Constructor to create a Result instance of a successful verification the verifier has a concern about + * + * @param record SignatureRecord + * @param warning The concern, null if none + */ + public Result(SignatureRecord record, String warning) { this.errorMessage = null; this.dkimRawField = null; this.record = record; this.type = Type.PASS; + this.warning = warning; } /** @@ -111,7 +124,7 @@ public class Result { String reasonMsg; switch (type) { case PASS: - reasonMsg = "valid signature"; + reasonMsg = warning != null ? "valid signature, but " + warning : "valid signature"; break; case NONE: reasonMsg = "unknown error"; @@ -180,6 +193,16 @@ public class Result { return errorMessage; } + /** + * Get the concern the verifier has about a successful verification, for instance an unsigned + * extra instance of a single-instance field (see {@link VerifierOptions.Builder#withUnsignedDuplicateFieldPolicy}). + * + * @return the warning, or null if none + */ + public String getWarning() { + return warning; + } + /** * Get dkim field * @@ -228,6 +251,7 @@ public class Result { ", errorMessage='" + errorMessage + '\'' + ", dkimRawField='" + dkimRawField + '\'' + ", type=" + type + + ", warning='" + warning + '\'' + '}'; } } diff --git a/main/src/main/java/org/apache/james/jdkim/api/UnsignedDuplicateFieldPolicy.java b/main/src/main/java/org/apache/james/jdkim/api/UnsignedDuplicateFieldPolicy.java new file mode 100644 index 0000000..c15f22e --- /dev/null +++ b/main/src/main/java/org/apache/james/jdkim/api/UnsignedDuplicateFieldPolicy.java @@ -0,0 +1,41 @@ +/**************************************************************** + * Licensed to the Apache Software Foundation (ASF) under one * + * or more contributor license agreements. See the NOTICE file * + * distributed with this work for additional information * + * regarding copyright ownership. The ASF licenses this file * + * to you 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.james.jdkim.api; + +/** + * What the verifier does with a signature when the message holds more instances of a single-instance + * field (such as From:) than the signature's h= tag lists, so that at least one of them is not signed. + * + * @see <a href="https://datatracker.ietf.org/doc/html/rfc6376#section-8.15">RFC 6376 section 8.15</a> + */ +public enum UnsignedDuplicateFieldPolicy { + /** + * The signature is evaluated as usual: an unsigned extra instance does not change its result. + */ + ACCEPT, + /** + * The signature keeps its PASS result, which carries a warning (see {@link Result#getWarning()}). + */ + FLAG, + /** + * The signature fails. + */ + REJECT +} diff --git a/main/src/main/java/org/apache/james/jdkim/api/VerifierOptions.java b/main/src/main/java/org/apache/james/jdkim/api/VerifierOptions.java index c5252d8..fd5bb8c 100644 --- a/main/src/main/java/org/apache/james/jdkim/api/VerifierOptions.java +++ b/main/src/main/java/org/apache/james/jdkim/api/VerifierOptions.java @@ -25,6 +25,12 @@ import org.xbill.DNS.Lookup; import org.xbill.DNS.Resolver; import java.time.Duration; +import java.util.Arrays; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.Locale; +import java.util.Set; public class VerifierOptions { /** @@ -32,15 +38,29 @@ public class VerifierOptions { */ public static final String MAX_SIGNATURE_COUNT_PROPERTY = "james.dkim.max-signature.count"; private static final int DEFAULT_MAX_SIGNATURE_COUNT = 32; + /** + * System property holding the default of {@link Builder#withUnsignedDuplicateFieldPolicy(UnsignedDuplicateFieldPolicy)}. + */ + public static final String UNSIGNED_DUPLICATE_FIELD_POLICY_PROPERTY = "james.dkim.unsigned-duplicate-field.policy"; + private static final UnsignedDuplicateFieldPolicy DEFAULT_UNSIGNED_DUPLICATE_FIELD_POLICY = UnsignedDuplicateFieldPolicy.REJECT; + /** + * Default of {@link Builder#withSingleInstanceFields(Collection)}: the fields RFC 5322 section 3.6 allows once. + */ + public static final Set<String> DEFAULT_SINGLE_INSTANCE_FIELDS = Collections.unmodifiableSet(new LinkedHashSet<>(Arrays.asList( + "from", "sender", "reply-to", "to", "cc", "bcc", "subject", "date", "message-id", "in-reply-to", "references"))); private final Duration clockDriftTolerance; private final int maxSignatures; + private final UnsignedDuplicateFieldPolicy unsignedDuplicateFieldPolicy; + private final Set<String> singleInstanceFields; private final PublicKeyRecordRetriever publicKeyRecordRetriever; private final Resolver dnsResolver; public static class Builder { private Duration clockDriftTolerance = Duration.ofSeconds(300); private int maxSignatures = Integer.getInteger(MAX_SIGNATURE_COUNT_PROPERTY, DEFAULT_MAX_SIGNATURE_COUNT); + private UnsignedDuplicateFieldPolicy unsignedDuplicateFieldPolicy = defaultUnsignedDuplicateFieldPolicy(); + private Set<String> singleInstanceFields = DEFAULT_SINGLE_INSTANCE_FIELDS; private Resolver dnsResolver = Lookup.getDefaultResolver(); private PublicKeyRecordRetriever publicKeyRecordRetriever = new MultiplexingPublicKeyRecordRetriever( "dns", new DNSPublicKeyRecordRetriever(this.dnsResolver)); @@ -80,6 +100,47 @@ public class VerifierOptions { return this; } + /** + * Sets what happens to a signature when the message holds more instances of a single-instance field + * (see {@link #withSingleInstanceFields(Collection)}) listed in its h= tag than h= lists, and more than one + * in total: + * the extra instance is not signed, yet mail clients usually display the top-most one. + * See RFC 6376 section 8.15. Signers protect themselves by listing the field once more in h= + * than it appears ("h=from:from"). Defaults to the + * {@value #UNSIGNED_DUPLICATE_FIELD_POLICY_PROPERTY} system property, or {@link UnsignedDuplicateFieldPolicy#REJECT}. + * + * @param unsignedDuplicateFieldPolicy an {@link UnsignedDuplicateFieldPolicy} + * @return {@link Builder} + */ + public Builder withUnsignedDuplicateFieldPolicy(UnsignedDuplicateFieldPolicy unsignedDuplicateFieldPolicy) { + this.unsignedDuplicateFieldPolicy = unsignedDuplicateFieldPolicy; + return this; + } + + /** + * Sets the names of the fields expected once per message, checked by + * {@link #withUnsignedDuplicateFieldPolicy(UnsignedDuplicateFieldPolicy)}. Case-insensitive. + * Defaults to the fields RFC 5322 section 3.6 allows once ({@link #DEFAULT_SINGLE_INSTANCE_FIELDS}). + * + * @param singleInstanceFields field names + * @return {@link Builder} + */ + public Builder withSingleInstanceFields(Collection<String> singleInstanceFields) { + if (singleInstanceFields == null) { + this.singleInstanceFields = null; + return this; + } + Set<String> names = new LinkedHashSet<>(); + for (String name : singleInstanceFields) { + if (name == null || name.trim().isEmpty()) { + throw new IllegalArgumentException("singleInstanceFields can not hold a blank name"); + } + names.add(name.trim().toLowerCase(Locale.US)); + } + this.singleInstanceFields = Collections.unmodifiableSet(names); + return this; + } + /** * A custom dns resolver * @@ -108,6 +169,14 @@ public class VerifierOptions { throw new IllegalArgumentException("maxSignatures must be strictly positive"); } + if (builder.unsignedDuplicateFieldPolicy == null) { + throw new IllegalArgumentException("unsignedDuplicateFieldPolicy can not be null"); + } + + if (builder.singleInstanceFields == null) { + throw new IllegalArgumentException("singleInstanceFields can not be null"); + } + if (builder.publicKeyRecordRetriever == null) { throw new IllegalArgumentException("publicKeyRecordRetriever can not be null"); } @@ -118,6 +187,8 @@ public class VerifierOptions { this.clockDriftTolerance = builder.clockDriftTolerance; this.maxSignatures = builder.maxSignatures; + this.unsignedDuplicateFieldPolicy = builder.unsignedDuplicateFieldPolicy; + this.singleInstanceFields = builder.singleInstanceFields; this.dnsResolver = builder.dnsResolver; this.publicKeyRecordRetriever = builder.publicKeyRecordRetriever; } @@ -140,6 +211,36 @@ public class VerifierOptions { return maxSignatures; } + /** + * Gets what happens to a signature leaving an instance of a single-instance field unsigned + * + * @return {@link UnsignedDuplicateFieldPolicy} + */ + public UnsignedDuplicateFieldPolicy getUnsignedDuplicateFieldPolicy() { + return unsignedDuplicateFieldPolicy; + } + + /** + * Gets the lower-cased names of the fields expected once per message + * + * @return field names + */ + public Set<String> getSingleInstanceFields() { + return singleInstanceFields; + } + + private static UnsignedDuplicateFieldPolicy defaultUnsignedDuplicateFieldPolicy() { + String value = System.getProperty(UNSIGNED_DUPLICATE_FIELD_POLICY_PROPERTY); + if (value == null) { + return DEFAULT_UNSIGNED_DUPLICATE_FIELD_POLICY; + } + try { + return UnsignedDuplicateFieldPolicy.valueOf(value.trim().toUpperCase(Locale.US)); + } catch (IllegalArgumentException e) { + throw new IllegalArgumentException("Invalid " + UNSIGNED_DUPLICATE_FIELD_POLICY_PROPERTY + " value: " + value, e); + } + } + /** * Gets current PublicKeyRecordRetriever instance * diff --git a/main/src/main/java/org/apache/james/jdkim/impl/CompoundBodyHasher.java b/main/src/main/java/org/apache/james/jdkim/impl/CompoundBodyHasher.java index e1fd359..bfe8076 100644 --- a/main/src/main/java/org/apache/james/jdkim/impl/CompoundBodyHasher.java +++ b/main/src/main/java/org/apache/james/jdkim/impl/CompoundBodyHasher.java @@ -44,6 +44,7 @@ public class CompoundBodyHasher implements BodyHasher { private final String signatureField; private BodyHasherImpl bodyHasher; private FailException failure; + private String warning; public Job(String signatureField) { this.signatureField = signatureField; @@ -65,6 +66,17 @@ public class CompoundBodyHasher implements BodyHasher { return failure; } + /** + * @return the warning to attach to the result of a successful verification, or null + */ + public String getWarning() { + return warning; + } + + public void setWarning(String warning) { + this.warning = warning; + } + public void setFailure(FailException failure) { this.failure = failure; this.bodyHasher = null; diff --git a/main/src/test/java/org/apache/james/jdkim/DKIMTest.java b/main/src/test/java/org/apache/james/jdkim/DKIMTest.java index 4ad6d80..76f443e 100644 --- a/main/src/test/java/org/apache/james/jdkim/DKIMTest.java +++ b/main/src/test/java/org/apache/james/jdkim/DKIMTest.java @@ -48,7 +48,9 @@ import org.apache.james.jdkim.MockPublicKeyRecordRetriever.Record; import org.apache.james.jdkim.api.Headers; import org.apache.james.jdkim.api.Result; import org.apache.james.jdkim.api.SignatureRecord; +import org.apache.james.jdkim.api.UnsignedDuplicateFieldPolicy; import org.apache.james.jdkim.api.VerifierOptions; +import org.apache.james.jdkim.exceptions.FailException; import org.apache.james.jdkim.exceptions.CompositeFailException; import org.apache.james.jdkim.exceptions.PermFailException; import org.apache.james.jdkim.impl.Message; @@ -476,4 +478,128 @@ public class DKIMTest { } }; } + + private static final String SINGLE_FROM_MESSAGE = "From: [email protected]\r\nSubject: hello\r\n\r\nbody\r\n"; + + private String replayWithExtraFrom(String template) throws Exception { + DKIMSigner signer = new DKIMSigner(template, TestKeys.privateKey); + String signature = signer.sign(new ByteArrayInputStream(SINGLE_FROM_MESSAGE.getBytes(StandardCharsets.US_ASCII))); + return "From: [email protected]\r\n" + signature + "\r\n" + SINGLE_FROM_MESSAGE; + } + + private DKIMVerifier verifierWith(UnsignedDuplicateFieldPolicy policy) { + return new DKIMVerifier(new VerifierOptions.Builder() + .withPublicKeyRecordRetriever(keyRecordRetriever) + .withUnsignedDuplicateFieldPolicy(policy) + .build()); + } + + @Test + public void unsignedExtraFromFieldShouldNotPassSilently() throws Exception { + String replayed = replayWithExtraFrom("v=1; a=rsa-sha256; c=simple; d=messiah.edu; h=from:subject; q=dns/txt; s=selector2;"); + + DKIMVerifier strictVerifier = verifierWith(UnsignedDuplicateFieldPolicy.REJECT); + + assertThatThrownBy(() -> strictVerifier.verify(new ByteArrayInputStream(replayed.getBytes(StandardCharsets.US_ASCII)))) + .isInstanceOf(FailException.class) + .hasMessageContaining("2 from fields"); + assertThat(strictVerifier.hasAnyValidSignature()).isFalse(); + assertThat(strictVerifier.getResults()).singleElement() + .satisfies(r -> assertThat(r.getErrorMessage()).contains("2 from fields")); + } + + @Test + public void unsignedExtraFromFieldShouldBeRejectedByDefault() throws Exception { + String replayed = replayWithExtraFrom("v=1; a=rsa-sha256; c=simple; d=messiah.edu; h=from:subject; q=dns/txt; s=selector2;"); + + assertThatThrownBy(() -> verifier.verify(new ByteArrayInputStream(replayed.getBytes(StandardCharsets.US_ASCII)))) + .isInstanceOf(FailException.class); + assertThat(verifier.hasAnyValidSignature()).isFalse(); + } + + @Test + public void unsignedExtraFromFieldShouldBeFlaggedWhenConfigured() throws Exception { + String replayed = replayWithExtraFrom("v=1; a=rsa-sha256; c=simple; d=messiah.edu; h=from:subject; q=dns/txt; s=selector2;"); + + DKIMVerifier flaggingVerifier = verifierWith(UnsignedDuplicateFieldPolicy.FLAG); + + assertThat(flaggingVerifier.verify(new ByteArrayInputStream(replayed.getBytes(StandardCharsets.US_ASCII)))).hasSize(1); + assertThat(flaggingVerifier.getResults()).singleElement().satisfies(r -> { + assertThat(r.getResultType()).isEqualTo(Result.Type.PASS); + assertThat(r.getWarning()).contains("2 from fields"); + assertThat(r.getHeaderTextWithReason()).contains("reason=\"valid signature, but the message has 2 from fields"); + }); + } + + @Test + public void unsignedExtraFromFieldShouldPassWhenAccepted() throws Exception { + String replayed = replayWithExtraFrom("v=1; a=rsa-sha256; c=simple; d=messiah.edu; h=from:subject; q=dns/txt; s=selector2;"); + + DKIMVerifier lenientVerifier = verifierWith(UnsignedDuplicateFieldPolicy.ACCEPT); + + assertThat(lenientVerifier.verify(new ByteArrayInputStream(replayed.getBytes(StandardCharsets.US_ASCII)))).hasSize(1); + assertThat(lenientVerifier.getResults()).singleElement() + .satisfies(r -> assertThat(r.getWarning()).isNull()); + } + + @Test + public void oversignedFromShouldStillFailWhenAFromFieldIsAdded() throws Exception { + String replayed = replayWithExtraFrom("v=1; a=rsa-sha256; c=simple; d=messiah.edu; h=from:from:subject; q=dns/txt; s=selector2;"); + + DKIMVerifier lenientVerifier = verifierWith(UnsignedDuplicateFieldPolicy.ACCEPT); + + assertThatThrownBy(() -> lenientVerifier.verify(new ByteArrayInputStream(replayed.getBytes(StandardCharsets.US_ASCII)))) + .isInstanceOf(PermFailException.class) + .hasMessageContaining("Header signature does not verify"); + } + + @Test + public void singleFromFieldShouldPassWithoutWarning() throws Exception { + DKIMSigner signer = new DKIMSigner("v=1; a=rsa-sha256; c=simple; d=messiah.edu; h=from:subject; q=dns/txt; s=selector2;", TestKeys.privateKey); + String signature = signer.sign(new ByteArrayInputStream(SINGLE_FROM_MESSAGE.getBytes(StandardCharsets.US_ASCII))); + String signed = signature + "\r\n" + SINGLE_FROM_MESSAGE; + + DKIMVerifier flaggingVerifier = verifierWith(UnsignedDuplicateFieldPolicy.FLAG); + + assertThat(flaggingVerifier.verify(new ByteArrayInputStream(signed.getBytes(StandardCharsets.US_ASCII)))).hasSize(1); + assertThat(flaggingVerifier.getResults()).singleElement() + .satisfies(r -> assertThat(r.getWarning()).isNull()); + } + + @Test + public void unsignedExtraReplyToFieldShouldNotPassSilently() throws Exception { + DKIMSigner signer = new DKIMSigner( + "v=1; a=rsa-sha256; c=simple; d=messiah.edu; h=from:reply-to:subject; q=dns/txt; s=selector2;", + TestKeys.privateKey); + String message = "From: [email protected]\r\nReply-To: [email protected]\r\nSubject: hello\r\n\r\nbody\r\n"; + String signature = signer.sign(new ByteArrayInputStream(message.getBytes(StandardCharsets.US_ASCII))); + String replayed = "Reply-To: [email protected]\r\n" + signature + "\r\n" + message; + + assertThatThrownBy(() -> verifier.verify(new ByteArrayInputStream(replayed.getBytes(StandardCharsets.US_ASCII)))) + .isInstanceOf(FailException.class) + .hasMessageContaining("2 reply-to fields"); + assertThat(verifier.hasAnyValidSignature()).isFalse(); + } + + @Test + public void duplicateFieldNotListedInHShouldNotBeChecked() throws Exception { + String message = "To: [email protected]\r\n" + SINGLE_FROM_MESSAGE; + DKIMSigner signer = new DKIMSigner("v=1; a=rsa-sha256; c=simple; d=messiah.edu; h=from:subject; q=dns/txt; s=selector2;", TestKeys.privateKey); + String signature = signer.sign(new ByteArrayInputStream(message.getBytes(StandardCharsets.US_ASCII))); + String withExtraTo = "To: [email protected]\r\n" + signature + "\r\n" + message; + + assertThat(verifier.verify(new ByteArrayInputStream(withExtraTo.getBytes(StandardCharsets.US_ASCII)))).hasSize(1); + } + + @Test + public void configuredSingleInstanceFieldsShouldBeChecked() throws Exception { + String replayed = replayWithExtraFrom("v=1; a=rsa-sha256; c=simple; d=messiah.edu; h=from:subject; q=dns/txt; s=selector2;"); + + DKIMVerifier subjectOnlyVerifier = new DKIMVerifier(new VerifierOptions.Builder() + .withPublicKeyRecordRetriever(keyRecordRetriever) + .withSingleInstanceFields(Collections.singletonList("Subject")) + .build()); + + assertThat(subjectOnlyVerifier.verify(new ByteArrayInputStream(replayed.getBytes(StandardCharsets.US_ASCII)))).hasSize(1); + } } diff --git a/main/src/test/java/org/apache/james/jdkim/DKIMVerifierOptionsTest.java b/main/src/test/java/org/apache/james/jdkim/DKIMVerifierOptionsTest.java index e185016..c55ed62 100644 --- a/main/src/test/java/org/apache/james/jdkim/DKIMVerifierOptionsTest.java +++ b/main/src/test/java/org/apache/james/jdkim/DKIMVerifierOptionsTest.java @@ -20,6 +20,7 @@ package org.apache.james.jdkim; import org.apache.james.jdkim.api.PublicKeyRecordRetriever; +import org.apache.james.jdkim.api.UnsignedDuplicateFieldPolicy; import org.apache.james.jdkim.api.VerifierOptions; import org.apache.james.jdkim.impl.DNSPublicKeyRecordRetriever; import org.apache.james.jdkim.impl.MultiplexingPublicKeyRecordRetriever; @@ -31,6 +32,8 @@ import org.xbill.DNS.SimpleResolver; import java.net.InetSocketAddress; import java.net.UnknownHostException; import java.time.Duration; +import java.util.Arrays; +import java.util.LinkedHashSet; import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; @@ -130,4 +133,45 @@ public class DKIMVerifierOptionsTest { public void shouldRejectNonPositiveMaxSignatures() { new VerifierOptions.Builder().withMaxSignatures(0).build(); } + + @Test + public void shouldRejectUnsignedDuplicateFieldsByDefault() { + VerifierOptions opt = new VerifierOptions.Builder().build(); + assertEquals(UnsignedDuplicateFieldPolicy.REJECT, opt.getUnsignedDuplicateFieldPolicy()); + assertEquals(new LinkedHashSet<>(Arrays.asList("from", "sender", "reply-to", "to", "cc", "bcc", "subject", + "date", "message-id", "in-reply-to", "references")), opt.getSingleInstanceFields()); + } + + @Test + public void shouldReadUnsignedDuplicateFieldPolicyFromSystemProperty() { + System.setProperty(VerifierOptions.UNSIGNED_DUPLICATE_FIELD_POLICY_PROPERTY, "flag"); + try { + VerifierOptions opt = new VerifierOptions.Builder().build(); + assertEquals(UnsignedDuplicateFieldPolicy.FLAG, opt.getUnsignedDuplicateFieldPolicy()); + } finally { + System.clearProperty(VerifierOptions.UNSIGNED_DUPLICATE_FIELD_POLICY_PROPERTY); + } + } + + @Test + public void shouldReturnCorrectUnsignedDuplicateFieldPolicy() { + VerifierOptions opt = new VerifierOptions.Builder().withUnsignedDuplicateFieldPolicy(UnsignedDuplicateFieldPolicy.ACCEPT).build(); + assertEquals(UnsignedDuplicateFieldPolicy.ACCEPT, opt.getUnsignedDuplicateFieldPolicy()); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldRejectNullUnsignedDuplicateFieldPolicy() { + new VerifierOptions.Builder().withUnsignedDuplicateFieldPolicy(null).build(); + } + + @Test + public void shouldLowerCaseSingleInstanceFields() { + VerifierOptions opt = new VerifierOptions.Builder().withSingleInstanceFields(Arrays.asList("From", " SUBJECT ")).build(); + assertEquals(new LinkedHashSet<>(Arrays.asList("from", "subject")), opt.getSingleInstanceFields()); + } + + @Test(expected = IllegalArgumentException.class) + public void shouldRejectNullSingleInstanceFields() { + new VerifierOptions.Builder().withSingleInstanceFields(null).build(); + } } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
