Skip to content

Commit bf3d30a

Browse files
committed
Fix WSS4J WS-Security replay detection for validation
Wire Apache WSS4J ReplayCache instances into RequestData from Wss4jSecurityInterceptor. Introduce SpringReplayCache as a Spring Cache-backed ReplayCache that stores expiry instants and enforces them in the contains method (no dependency on the backing cache's own TTL). Also introduced ConcurrentMapReplayCache for single-node use that use an in-memory cache. Closes gh-1837
1 parent 5121a4e commit bf3d30a

10 files changed

Lines changed: 623 additions & 1 deletion

File tree

spring-ws-docs/src/docs/asciidoc/security.adoc

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,12 @@ The default is `false`, in line with WSS4J.
147147
Set it to `true` only when a partner cannot use RSA-OAEP.
148148
For **outbound** encryption, prefer `setSecurementEncryptionKeyTransportAlgorithm` with `http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p` (or the constant exposed by WSS4J) when the recipient supports it.
149149

150+
==== Replay detection during validation
151+
152+
When you validate `UsernameToken` elements that include `Nonce` and `Created`, validate `Timestamp` tokens, or validate SAML assertions with one-time-use semantics, configure `setValidationReplayCache` with an Apache WSS4J `org.apache.wss4j.common.cache.ReplayCache` implementation.
153+
Spring-WS registers that cache on the WSS4J `RequestData` for nonce, timestamp, and SAML one-time-use replay checks.
154+
See `org.springframework.ws.soap.security.wss4j2.cache.SpringReplayCache` for an implementation based on Spring's Cache abstraction.
155+
150156
== Handling Digital Certificates
151157

152158
For cryptographic operations that require interaction with a keystore or certificate handling (signature, encryption, and decryption operations), WSS4J requires an instance of `org.apache.ws.security.components.crypto.Crypto`.

spring-ws-security/build.gradle

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ dependencies {
3535
}
3636
api("org.jvnet.staxex:stax-ex")
3737
api("org.slf4j:slf4j-api")
38-
api("org.springframework:spring-beans")
38+
api("org.springframework:spring-context")
3939
api("org.springframework:spring-tx")
4040
api("org.springframework.security:spring-security-core")
4141

spring-ws-security/src/main/java/org/springframework/ws/soap/security/wss4j2/Wss4jSecurityInterceptor.java

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
import javax.security.auth.callback.UnsupportedCallbackException;
3030

3131
import org.apache.wss4j.common.ConfigurationConstants;
32+
import org.apache.wss4j.common.cache.ReplayCache;
3233
import org.apache.wss4j.common.crypto.Crypto;
3334
import org.apache.wss4j.common.ext.WSSecurityException;
3435
import org.apache.wss4j.common.principal.WSUsernameTokenPrincipalImpl;
@@ -137,6 +138,16 @@
137138
* <p>
138139
* The order of the actions that the client performed to secure the messages is
139140
* significant and is enforced by the interceptor.
141+
* <p>
142+
* For validation, configure an Apache WSS4J {@link ReplayCache} with
143+
* {@link #setValidationReplayCache(ReplayCache)} so username-token nonces and (when
144+
* applicable) timestamp/signature and SAML one-time-use replay checks run across
145+
* requests. By default, the same cache is used for all three; override
146+
* {@link #getValidationNonceReplayCache()}, {@link #getValidationTimestampReplayCache()},
147+
* or {@link #getValidationSamlOneTimeUseReplayCache()} to use different caches. See
148+
* {@link org.springframework.ws.soap.security.wss4j2.cache.SpringReplayCache
149+
* SpringReplayCache} for a convenient implementation based on Spring's
150+
* {@link org.springframework.cache.Cache Cache abstraction}.
140151
*
141152
* @author Tareq Abed Rabbo
142153
* @author Arjen Poutsma
@@ -211,6 +222,8 @@ public class Wss4jSecurityInterceptor extends AbstractWsSecurityInterceptor impl
211222
// To maintain same behavior as default, this flag is set to true
212223
private boolean removeSecurityHeader = true;
213224

225+
private ReplayCache validationReplayCache;
226+
214227
private List<Pattern> signatureSubjectDnPatterns = Collections.emptyList();
215228

216229
/**
@@ -703,6 +716,45 @@ public void setRemoveSecurityHeader(boolean removeSecurityHeader) {
703716
this.removeSecurityHeader = removeSecurityHeader;
704717
}
705718

719+
/**
720+
* Set the {@link ReplayCache} used during message validation for username-token
721+
* nonces, timestamp/signature replay detection, and SAML one-time-use assertions.
722+
* @since 3.1.9
723+
*/
724+
public void setValidationReplayCache(ReplayCache validationReplayCache) {
725+
this.validationReplayCache = validationReplayCache;
726+
}
727+
728+
/**
729+
* Return the replay cache for {@code UsernameToken} nonces. The default
730+
* implementation returns the cache configured with
731+
* {@link #setValidationReplayCache(ReplayCache)}.
732+
* @since 3.1.9
733+
*/
734+
protected ReplayCache getValidationNonceReplayCache() {
735+
return this.validationReplayCache;
736+
}
737+
738+
/**
739+
* Return the replay cache for timestamp/signature replay detection. The default
740+
* implementation returns the cache configured with
741+
* {@link #setValidationReplayCache(ReplayCache)}.
742+
* @since 3.1.9
743+
*/
744+
protected ReplayCache getValidationTimestampReplayCache() {
745+
return this.validationReplayCache;
746+
}
747+
748+
/**
749+
* Return the replay cache for SAML {@code OneTimeUse} assertions. The default
750+
* implementation returns the cache configured with
751+
* {@link #setValidationReplayCache(ReplayCache)}.
752+
* @since 3.1.9
753+
*/
754+
protected ReplayCache getValidationSamlOneTimeUseReplayCache() {
755+
return this.validationReplayCache;
756+
}
757+
706758
@Override
707759
public void afterPropertiesSet() throws Exception {
708760

@@ -829,6 +881,19 @@ protected RequestData initializeValidationRequestData(MessageContext messageCont
829881
// allow for qualified password types for .Net interoperability
830882
requestData.setAllowNamespaceQualifiedPasswordTypes(true);
831883

884+
ReplayCache nonceReplayCache = getValidationNonceReplayCache();
885+
if (nonceReplayCache != null) {
886+
requestData.setNonceReplayCache(nonceReplayCache);
887+
}
888+
ReplayCache timestampReplayCache = getValidationTimestampReplayCache();
889+
if (timestampReplayCache != null) {
890+
requestData.setTimestampReplayCache(timestampReplayCache);
891+
}
892+
ReplayCache samlOneTimeUseReplayCache = getValidationSamlOneTimeUseReplayCache();
893+
if (samlOneTimeUseReplayCache != null) {
894+
requestData.setSamlOneTimeUseReplayCache(samlOneTimeUseReplayCache);
895+
}
896+
832897
requestData.setSubjectCertConstraints(this.signatureSubjectDnPatterns);
833898
return requestData;
834899
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/*
2+
* Copyright 2005-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ws.soap.security.wss4j2.cache;
18+
19+
import java.time.Instant;
20+
import java.util.ArrayList;
21+
import java.util.List;
22+
import java.util.Map;
23+
import java.util.concurrent.ConcurrentMap;
24+
25+
import org.springframework.cache.concurrent.ConcurrentMapCache;
26+
27+
/**
28+
* {@link SpringReplayCache} backed by an internal {@link ConcurrentMapCache} that sweeps
29+
* all expired entries on cache access so keys that are never read again are still removed
30+
* once they expire.
31+
* <p>
32+
* Use this implementation only with single host deployment. Services that are deployment
33+
* on multiple hosts should use a distributed cache, see {@link SpringReplayCache} for
34+
* options.
35+
*
36+
* @author Stephane Nicoll
37+
* @since 3.1.9
38+
*/
39+
public class ConcurrentMapReplayCache extends SpringReplayCache {
40+
41+
public ConcurrentMapReplayCache() {
42+
this(DEFAULT_TTL, MAX_TTL);
43+
}
44+
45+
public ConcurrentMapReplayCache(long defaultTtlSeconds, long maxTtlSeconds) {
46+
super(new ConcurrentMapCache("wss4j-replay"), defaultTtlSeconds, maxTtlSeconds);
47+
}
48+
49+
@Override
50+
protected void onCacheAccess() {
51+
Instant now = Instant.now();
52+
ConcurrentMapCache mapCache = (ConcurrentMapCache) getCache();
53+
ConcurrentMap<Object, Object> store = mapCache.getNativeCache();
54+
List<Object> keysToRemove = new ArrayList<>();
55+
for (Map.Entry<Object, Object> entry : store.entrySet()) {
56+
Object key = entry.getKey();
57+
Object value = entry.getValue();
58+
if (value instanceof Instant instant && instant.isBefore(now)) {
59+
keysToRemove.add(key);
60+
}
61+
}
62+
for (Object key : keysToRemove) {
63+
mapCache.evict(key);
64+
}
65+
}
66+
67+
}
Lines changed: 143 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,143 @@
1+
/*
2+
* Copyright 2005-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
package org.springframework.ws.soap.security.wss4j2.cache;
18+
19+
import java.time.Instant;
20+
21+
import org.apache.wss4j.common.cache.MemoryReplayCache;
22+
import org.apache.wss4j.common.cache.ReplayCache;
23+
24+
import org.springframework.cache.Cache;
25+
import org.springframework.util.Assert;
26+
27+
/**
28+
* Apache WSS4J {@link ReplayCache} backed by a Spring {@link Cache}. Each key stores an
29+
* expiry {@link Instant} with configurable TTLs. The default TTL is 5 minutes and the max
30+
* TTL is an hour.
31+
* <p>
32+
* {@link #contains} consults that instant and evicts the entry when it is past expiry.
33+
* This does not rely on the backing cache's own TTL configuration.
34+
* <p>
35+
* Subclasses may override {@link #onCacheAccess()} and to perform additional eviction,
36+
* for example, a full sweep over an in-memory cache.
37+
* <p>
38+
* For multi hosts deployment in production, make sure to use a {@link Cache}
39+
* implementation that is distributed, as each host needs to access the same data. For
40+
* simple, single host deployment, {@link ConcurrentMapReplayCache} can be used.
41+
*
42+
* @author Stephane Nicoll
43+
* @since 3.1.9
44+
* @see ConcurrentMapReplayCache
45+
*/
46+
public class SpringReplayCache implements ReplayCache {
47+
48+
/**
49+
* Default TTL for entries in seconds.
50+
*/
51+
public static final long DEFAULT_TTL = MemoryReplayCache.DEFAULT_TTL;
52+
53+
/**
54+
* Maximum TTL for entries in seconds.
55+
*/
56+
public static final long MAX_TTL = MemoryReplayCache.MAX_TTL;
57+
58+
private final Cache cache;
59+
60+
private final long defaultTtlSeconds;
61+
62+
private final long maxTtlSeconds;
63+
64+
/**
65+
* Create an instance with the given cache and default TTLs.
66+
* @param cache the cache to use
67+
*/
68+
public SpringReplayCache(Cache cache) {
69+
this(cache, DEFAULT_TTL, MAX_TTL);
70+
}
71+
72+
/**
73+
* Create an instance with the given cache and TTLs.
74+
* @param cache the cache to use
75+
* @param defaultTtlSeconds the default TTL in seconds
76+
* @param maxTtlSeconds the maximum TTL in seconds
77+
*/
78+
public SpringReplayCache(Cache cache, long defaultTtlSeconds, long maxTtlSeconds) {
79+
Assert.notNull(cache, "cache must not be null");
80+
Assert.isTrue(defaultTtlSeconds > 0, "defaultTtlSeconds must be positive");
81+
Assert.isTrue(maxTtlSeconds >= defaultTtlSeconds, "maxTtlSeconds must be >= defaultTtlSeconds");
82+
this.cache = cache;
83+
this.defaultTtlSeconds = defaultTtlSeconds;
84+
this.maxTtlSeconds = maxTtlSeconds;
85+
}
86+
87+
protected final Cache getCache() {
88+
return this.cache;
89+
}
90+
91+
@Override
92+
public void add(String identifier) {
93+
add(identifier, Instant.now().plusSeconds(this.defaultTtlSeconds));
94+
}
95+
96+
@Override
97+
public void add(String identifier, Instant expiry) {
98+
if (identifier == null || identifier.isEmpty()) {
99+
return;
100+
}
101+
Instant now = Instant.now();
102+
Instant maxExpiry = now.plusSeconds(this.maxTtlSeconds);
103+
if (expiry == null || expiry.isBefore(now) || expiry.isAfter(maxExpiry)) {
104+
expiry = now.plusSeconds(this.defaultTtlSeconds);
105+
}
106+
this.cache.put(identifier, expiry);
107+
onCacheAccess();
108+
}
109+
110+
@Override
111+
public boolean contains(String identifier) {
112+
if (identifier == null || identifier.isEmpty()) {
113+
return false;
114+
}
115+
onCacheAccess();
116+
Cache.ValueWrapper wrapper = this.cache.get(identifier);
117+
if (wrapper == null) {
118+
return false;
119+
}
120+
Object value = wrapper.get();
121+
if (!(value instanceof Instant exp)) {
122+
return false;
123+
}
124+
if (exp.isBefore(Instant.now())) {
125+
this.cache.evict(identifier);
126+
return false;
127+
}
128+
return true;
129+
}
130+
131+
/**
132+
* Invoked when the cache is accessed, either after an entry has been stored or before
133+
* checking for the presence of one.
134+
*/
135+
protected void onCacheAccess() {
136+
}
137+
138+
@Override
139+
public void close() {
140+
this.cache.clear();
141+
}
142+
143+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
/*
2+
* Copyright 2005-present the original author or authors.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
/**
18+
* Spring-backed {@link org.apache.wss4j.common.cache.ReplayCache} implementations for
19+
* WSS4J WS-Security.
20+
*/
21+
package org.springframework.ws.soap.security.wss4j2.cache;

spring-ws-security/src/test/java/org/springframework/ws/soap/security/wss4j2/Wss4jInterceptorTests.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@
1616

1717
package org.springframework.ws.soap.security.wss4j2;
1818

19+
import org.apache.wss4j.common.cache.MemoryReplayCache;
20+
import org.apache.wss4j.common.cache.ReplayCache;
1921
import org.apache.wss4j.dom.engine.WSSecurityEngine;
2022
import org.apache.wss4j.dom.handler.RequestData;
2123
import org.junit.jupiter.api.Test;
@@ -148,4 +150,19 @@ void rsa15KeyTransportAlgorithmCanBeEnabled() throws Exception {
148150
assertThat(requestData.isAllowRSA15KeyTransportAlgorithm()).isTrue();
149151
}
150152

153+
@Test
154+
void validationReplayCachesArePropagatedToRequestData() throws Exception {
155+
WSSecurityEngine engine = new WSSecurityEngine();
156+
Wss4jSecurityInterceptor interceptor = new Wss4jSecurityInterceptor(engine);
157+
ReplayCache replayCache = new MemoryReplayCache();
158+
interceptor.setValidationReplayCache(replayCache);
159+
160+
SoapMessage request = loadSoap11Message("empty-soap.xml");
161+
MessageContext context = new DefaultMessageContext(request, getSoap11MessageFactory());
162+
RequestData requestData = interceptor.initializeValidationRequestData(context);
163+
assertThat(requestData.getNonceReplayCache()).isSameAs(replayCache);
164+
assertThat(requestData.getTimestampReplayCache()).isSameAs(replayCache);
165+
assertThat(requestData.getSamlOneTimeUseReplayCache()).isSameAs(replayCache);
166+
}
167+
151168
}

0 commit comments

Comments
 (0)