Skip to content

Commit 99125cf

Browse files
aaronziFriedJannik
andauthored
fix(security): mitigate blind SSRF in operation delegation (CWE-918) (#1001)
* Tries to fix incorrect supplementalSemanticIds * Adds Legacy Compatibility * Fixes test failures and adds legacy support to aas registry * fix(security): mitigate blind SSRF in operation delegation (CWE-918) Add strict delegation target validation with secure defaults and explicit allowlist support. Block local/private/link-local/metadata destinations, reject redirects, and extend tests/docs including example config updates. * Fixes failing tests --------- Co-authored-by: FriedJannik <jannik.fried@iese.fraunhofer.de>
1 parent a67f1c2 commit 99125cf

20 files changed

Lines changed: 788 additions & 25 deletions

File tree

basyx.aasservice/basyx.aasservice-feature-mqtt/src/test/java/org/eclipse/digitaltwin/basyx/aasservice/feature/mqtt/TestMqttAasService.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -46,8 +46,8 @@
4646
import org.eclipse.digitaltwin.basyx.aasservice.backend.InMemoryAasBackend;
4747
import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.URLEncoder;
4848
import org.eclipse.digitaltwin.basyx.common.mqttcore.listener.MqttTestListener;
49-
import org.eclipse.digitaltwin.basyx.core.filerepository.FileMetadata;
5049
import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepository;
50+
import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepositoryHelper;
5151
import org.eclipse.digitaltwin.basyx.core.filerepository.InMemoryFileRepository;
5252
import org.eclipse.digitaltwin.basyx.http.Aas4JHTTPSerializationExtension;
5353
import org.eclipse.digitaltwin.basyx.http.BaSyxHTTPConfiguration;
@@ -109,9 +109,7 @@ protected AasService getAasServiceWithThumbnail() throws IOException {
109109
AssetAdministrationShell expected = DummyAssetAdministrationShellFactory.createForThumbnail();
110110
AasService aasServiceWithThumbnail = getAasService(expected);
111111

112-
FileMetadata defaultThumbnail = new FileMetadata("dummyImgA.jpeg", "", createDummyImageIS_A());
113-
114-
String thumbnailFilePath = fileRepository.save(defaultThumbnail);
112+
String thumbnailFilePath = FileRepositoryHelper.saveOrOverwriteFile(fileRepository, "dummyImgA.jpeg", "", createDummyImageIS_A());
115113

116114
Resource defaultResource = new DefaultResource.Builder().path(thumbnailFilePath).contentType("").build();
117115
AssetInformation defaultAasAssetInformation = aasServiceWithThumbnail.getAssetInformation();

basyx.submodelregistry/basyx.submodelregistry-service-basetests/src/main/java/org/eclipse/digitaltwin/basyx/submodelregistry/service/tests/integration/BaseIntegrationTest.java

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -143,8 +143,14 @@ protected void cleanup() throws ApiException, InterruptedException, Deserializat
143143
adapter.assertNoAdditionalMessages();
144144
GetSubmodelDescriptorsResult result = api.getAllSubmodelDescriptors(null, null);
145145
for (SubmodelDescriptor eachDescriptor : result.getResult()) {
146-
api.deleteSubmodelDescriptorById(eachDescriptor.getId());
147-
assertThatEventWasSend(RegistryEvent.builder().id(eachDescriptor.getId()).type(EventType.SUBMODEL_UNREGISTERED).build());
146+
try {
147+
api.deleteSubmodelDescriptorById(eachDescriptor.getId());
148+
assertThatEventWasSend(RegistryEvent.builder().id(eachDescriptor.getId()).type(EventType.SUBMODEL_UNREGISTERED).build());
149+
} catch (ApiException e) {
150+
if (e.getCode() != NOT_FOUND) {
151+
throw e;
152+
}
153+
}
148154
}
149155
}
150156

basyx.submodelrepository/basyx.submodelrepository-feature-authorization/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/authorization/AuthorizedSubmodelRepository.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -309,6 +309,11 @@ public InputStream getFileByFilePath(String submodelId, String filePath) {
309309
return decorated.getFileByFilePath(submodelId, filePath);
310310
}
311311

312+
@Override
313+
public String getOriginalFileNameByPath(String submodelId, String idShortPath) {
314+
return decorated.getOriginalFileNameByPath(submodelId, idShortPath);
315+
}
316+
312317
private List<String> getIdAsList(String id) {
313318
return new ArrayList<>(Arrays.asList(id));
314319
}

basyx.submodelrepository/basyx.submodelrepository-feature-kafka/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/kafka/KafkaSubmodelRepository.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,11 @@ public InputStream getFileByFilePath(String submodelId, String filePath) {
196196
return decorated.getFileByFilePath(submodelId, filePath);
197197
}
198198

199+
@Override
200+
public String getOriginalFileNameByPath(String submodelId, String idShortPath) {
201+
return decorated.getOriginalFileNameByPath(submodelId, idShortPath);
202+
}
203+
199204
@Override
200205
public CursorResult<List<Submodel>> getAllSubmodels(String semanticId, PaginationInfo pInfo) {
201206
return decorated.getAllSubmodels(semanticId, pInfo);

basyx.submodelrepository/basyx.submodelrepository-feature-mqtt/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/mqtt/MqttSubmodelRepository.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -202,6 +202,11 @@ public InputStream getFileByFilePath(String submodelId, String filePath) {
202202
return decorated.getFileByFilePath(submodelId, filePath);
203203
}
204204

205+
@Override
206+
public String getOriginalFileNameByPath(String submodelId, String idShortPath) {
207+
return decorated.getOriginalFileNameByPath(submodelId, idShortPath);
208+
}
209+
205210
private void submodelCreated(Submodel submodel, String repoId) {
206211
sendMqttMessage(topicFactory.createCreateSubmodelTopic(repoId), SubmodelSerializer.serializeSubmodel(submodel));
207212
}

basyx.submodelrepository/basyx.submodelrepository-feature-operation-delegation/Readme.md

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,3 +30,30 @@ This means that operations can be delegated to endpoints of the same server as w
3030
The independent destination where the request is to be delegated should support the request with a parameter ([OperationVariable[]](https://github.com/eclipse-aas4j/aas4j/blob/2abf04bc01f80bceafa575cf85da429d5fe63918/model/src/main/java/org/eclipse/digitaltwin/aas4j/v3/model/OperationVariable.java#L31)) and provide the output in a strict format ([OperationVariable[]](https://github.com/eclipse-aas4j/aas4j/blob/2abf04bc01f80bceafa575cf85da429d5fe63918/model/src/main/java/org/eclipse/digitaltwin/aas4j/v3/model/OperationVariable.java#L31))
3131

3232
As of now, only delegation to HTTP URLs is supported.
33+
34+
## Security defaults for operation delegation
35+
36+
Operation delegation now validates each outbound delegation URI before dispatch.
37+
38+
- Only `http` and `https` URIs are allowed.
39+
- Loopback, private, link-local, and metadata addresses are blocked by default.
40+
- Redirect responses are rejected.
41+
- Explicit allowlists can be configured for approved hybrid deployments.
42+
43+
Example configuration:
44+
45+
```
46+
basyx.submodelrepository.feature.operation.delegation.security.enabled = true
47+
basyx.submodelrepository.feature.operation.delegation.security.denyPrivateTargets = true
48+
basyx.submodelrepository.feature.operation.delegation.security.denyLinkLocalTargets = true
49+
basyx.submodelrepository.feature.operation.delegation.security.denyLoopbackTargets = true
50+
basyx.submodelrepository.feature.operation.delegation.security.denyMetadataTargets = true
51+
basyx.submodelrepository.feature.operation.delegation.security.denyRedirects = true
52+
53+
# Optional explicit overrides for trusted targets
54+
basyx.submodelrepository.feature.operation.delegation.security.allowlist.hosts = localhost,*.trusted.example
55+
basyx.submodelrepository.feature.operation.delegation.security.allowlist.cidrs = 10.42.0.0/16,127.0.0.0/8,::1/128
56+
basyx.submodelrepository.feature.operation.delegation.security.allowlist.ports = 443,8443
57+
```
58+
59+
If an existing setup delegates to localhost or private network endpoints, configure the corresponding allowlist entries explicitly.

basyx.submodelrepository/basyx.submodelrepository-feature-operation-delegation/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/operation/delegation/HTTPOperationDelegation.java

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ public class HTTPOperationDelegation implements OperationDelegation {
4242

4343
public static final String INVOCATION_DELEGATION_TYPE = "invocationDelegation";
4444

45-
private WebClient webClient;
45+
private final WebClient webClient;
4646

4747
public HTTPOperationDelegation(WebClient webClient) {
4848
this.webClient = webClient;
@@ -55,15 +55,23 @@ public OperationVariable[] delegate(Qualifier qualifier, OperationVariable[] inp
5555

5656
try {
5757
return webClient.post().uri(uri).contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON).body(BodyInserters.fromValue(input)).exchangeToMono(response -> {
58+
if (response.statusCode().is3xxRedirection()) {
59+
throw new OperationDelegationException(String.format("Unable to delegate the invocation operation on the URI: '%s' redirects are not allowed", uri));
60+
}
61+
5862
if (response.statusCode().isError()) {
5963
throw new OperationDelegationException(String.format("Unable to delegate the invocation operation on the URI: '%s' the response code is %s", uri, response.statusCode()));
6064
} else {
6165
return response.bodyToMono(OperationVariable[].class);
6266
}
6367
}).block();
6468

69+
} catch (OperationDelegationException e) {
70+
throw e;
6571
} catch (WebClientResponseException e) {
66-
throw new OperationDelegationException(String.format("Exception occurred while invocing operation on the URI: '%s' the error is %s", uri, e.getStackTrace()));
72+
throw new OperationDelegationException(String.format("Exception occurred while invoking operation on the URI: '%s' the error is %s", uri, e.getMessage()));
73+
} catch (Exception e) {
74+
throw new OperationDelegationException(String.format("Exception occurred while invoking operation on the URI: '%s' the error is %s", uri, e.getMessage()));
6775
}
6876

6977
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
/*******************************************************************************
2+
* Copyright (C) 2026 the Eclipse BaSyx Authors
3+
*
4+
* Permission is hereby granted, free of charge, to any person obtaining
5+
* a copy of this software and associated documentation files (the
6+
* "Software"), to deal in the Software without restriction, including
7+
* without limitation the rights to use, copy, modify, merge, publish,
8+
* distribute, sublicense, and/or sell copies of the Software, and to
9+
* permit persons to whom the Software is furnished to do so, subject to
10+
* the following conditions:
11+
*
12+
* The above copyright notice and this permission notice shall be
13+
* included in all copies or substantial portions of the Software.
14+
*
15+
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
16+
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
17+
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
18+
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
19+
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
20+
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
21+
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
22+
*
23+
* SPDX-License-Identifier: MIT
24+
******************************************************************************/
25+
26+
package org.eclipse.digitaltwin.basyx.submodelrepository.feature.operation.delegation;
27+
28+
import java.util.ArrayList;
29+
import java.util.List;
30+
31+
import org.springframework.boot.context.properties.ConfigurationProperties;
32+
33+
/**
34+
* Security properties for outbound operation delegation requests.
35+
*/
36+
@ConfigurationProperties(prefix = OperationDelegationSubmodelRepositoryFeature.FEATURENAME + ".security")
37+
public class OperationDelegationSecurityProperties {
38+
39+
private boolean enabled = true;
40+
private boolean denyPrivateTargets = true;
41+
private boolean denyLinkLocalTargets = true;
42+
private boolean denyLoopbackTargets = true;
43+
private boolean denyMetadataTargets = true;
44+
private boolean denyRedirects = true;
45+
private Allowlist allowlist = new Allowlist();
46+
47+
public boolean isEnabled() {
48+
return enabled;
49+
}
50+
51+
public void setEnabled(boolean enabled) {
52+
this.enabled = enabled;
53+
}
54+
55+
public boolean isDenyPrivateTargets() {
56+
return denyPrivateTargets;
57+
}
58+
59+
public void setDenyPrivateTargets(boolean denyPrivateTargets) {
60+
this.denyPrivateTargets = denyPrivateTargets;
61+
}
62+
63+
public boolean isDenyLinkLocalTargets() {
64+
return denyLinkLocalTargets;
65+
}
66+
67+
public void setDenyLinkLocalTargets(boolean denyLinkLocalTargets) {
68+
this.denyLinkLocalTargets = denyLinkLocalTargets;
69+
}
70+
71+
public boolean isDenyLoopbackTargets() {
72+
return denyLoopbackTargets;
73+
}
74+
75+
public void setDenyLoopbackTargets(boolean denyLoopbackTargets) {
76+
this.denyLoopbackTargets = denyLoopbackTargets;
77+
}
78+
79+
public boolean isDenyMetadataTargets() {
80+
return denyMetadataTargets;
81+
}
82+
83+
public void setDenyMetadataTargets(boolean denyMetadataTargets) {
84+
this.denyMetadataTargets = denyMetadataTargets;
85+
}
86+
87+
public boolean isDenyRedirects() {
88+
return denyRedirects;
89+
}
90+
91+
public void setDenyRedirects(boolean denyRedirects) {
92+
this.denyRedirects = denyRedirects;
93+
}
94+
95+
public Allowlist getAllowlist() {
96+
return allowlist;
97+
}
98+
99+
public void setAllowlist(Allowlist allowlist) {
100+
this.allowlist = allowlist;
101+
}
102+
103+
public static class Allowlist {
104+
private List<String> hosts = new ArrayList<>();
105+
private List<String> cidrs = new ArrayList<>();
106+
private List<Integer> ports = new ArrayList<>();
107+
108+
public List<String> getHosts() {
109+
return hosts;
110+
}
111+
112+
public void setHosts(List<String> hosts) {
113+
this.hosts = hosts;
114+
}
115+
116+
public List<String> getCidrs() {
117+
return cidrs;
118+
}
119+
120+
public void setCidrs(List<String> cidrs) {
121+
this.cidrs = cidrs;
122+
}
123+
124+
public List<Integer> getPorts() {
125+
return ports;
126+
}
127+
128+
public void setPorts(List<Integer> ports) {
129+
this.ports = ports;
130+
}
131+
}
132+
}

basyx.submodelrepository/basyx.submodelrepository-feature-operation-delegation/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/operation/delegation/OperationDelegationSubmodelRepository.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,4 +184,9 @@ public InputStream getFileByFilePath(String submodelId, String filePath) {
184184
return decorated.getFileByFilePath(submodelId, filePath);
185185
}
186186

187+
@Override
188+
public String getOriginalFileNameByPath(String submodelId, String idShortPath) {
189+
return decorated.getOriginalFileNameByPath(submodelId, idShortPath);
190+
}
191+
187192
}

basyx.submodelrepository/basyx.submodelrepository-feature-operation-delegation/src/main/java/org/eclipse/digitaltwin/basyx/submodelrepository/feature/operation/delegation/OperationDelegationSubmodelRepositoryConfiguration.java

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,10 +28,12 @@
2828
import org.eclipse.digitaltwin.basyx.submodelrepository.SubmodelRepository;
2929
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
3030
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
31+
import org.springframework.boot.context.properties.EnableConfigurationProperties;
3132
import org.springframework.context.annotation.Bean;
3233
import org.springframework.context.annotation.Configuration;
3334
import org.springframework.http.codec.json.Jackson2JsonDecoder;
3435
import org.springframework.http.codec.json.Jackson2JsonEncoder;
36+
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
3537
import org.springframework.web.reactive.function.client.ExchangeStrategies;
3638
import org.springframework.web.reactive.function.client.WebClient;
3739

@@ -44,22 +46,36 @@
4446
*/
4547
@Configuration
4648
@ConditionalOnExpression("${" + OperationDelegationSubmodelRepositoryFeature.FEATURENAME + ".enabled:true}")
49+
@EnableConfigurationProperties(OperationDelegationSecurityProperties.class)
4750
public class OperationDelegationSubmodelRepositoryConfiguration {
4851

4952
@Bean
5053
@ConditionalOnMissingBean
51-
public OperationDelegation getOperationDelegation(ObjectMapper mapper) {
54+
public OperationDelegationTargetValidator operationDelegationTargetValidator(OperationDelegationSecurityProperties securityProperties) {
55+
return new OperationDelegationTargetValidator(securityProperties);
56+
}
57+
58+
@Bean
59+
@ConditionalOnMissingBean
60+
public OperationDelegation getOperationDelegation(ObjectMapper mapper, OperationDelegationTargetValidator targetValidator) {
5261

53-
return new HTTPOperationDelegation(createWebClient(mapper));
62+
return new HTTPOperationDelegation(createWebClient(mapper, targetValidator));
5463
}
5564

56-
private WebClient createWebClient(ObjectMapper mapper) {
65+
private WebClient createWebClient(ObjectMapper mapper, OperationDelegationTargetValidator targetValidator) {
5766
ExchangeStrategies strategies = ExchangeStrategies.builder().codecs(configurer -> {
5867
configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(mapper));
5968
configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(mapper));
6069
}).build();
6170

62-
return WebClient.builder().exchangeStrategies(strategies).build();
71+
return WebClient.builder().exchangeStrategies(strategies).filter(validateTargetFilter(targetValidator)).build();
72+
}
73+
74+
private ExchangeFilterFunction validateTargetFilter(OperationDelegationTargetValidator targetValidator) {
75+
return (request, next) -> {
76+
targetValidator.validate(request.url());
77+
return next.exchange(request);
78+
};
6379
}
6480

6581
}

0 commit comments

Comments
 (0)