Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,8 @@
import org.eclipse.digitaltwin.basyx.aasservice.backend.InMemoryAasBackend;
import org.eclipse.digitaltwin.basyx.common.mqttcore.encoding.URLEncoder;
import org.eclipse.digitaltwin.basyx.common.mqttcore.listener.MqttTestListener;
import org.eclipse.digitaltwin.basyx.core.filerepository.FileMetadata;
import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepository;
import org.eclipse.digitaltwin.basyx.core.filerepository.FileRepositoryHelper;
import org.eclipse.digitaltwin.basyx.core.filerepository.InMemoryFileRepository;
import org.eclipse.digitaltwin.basyx.http.Aas4JHTTPSerializationExtension;
import org.eclipse.digitaltwin.basyx.http.BaSyxHTTPConfiguration;
Expand Down Expand Up @@ -109,9 +109,7 @@ protected AasService getAasServiceWithThumbnail() throws IOException {
AssetAdministrationShell expected = DummyAssetAdministrationShellFactory.createForThumbnail();
AasService aasServiceWithThumbnail = getAasService(expected);

FileMetadata defaultThumbnail = new FileMetadata("dummyImgA.jpeg", "", createDummyImageIS_A());

String thumbnailFilePath = fileRepository.save(defaultThumbnail);
String thumbnailFilePath = FileRepositoryHelper.saveOrOverwriteFile(fileRepository, "dummyImgA.jpeg", "", createDummyImageIS_A());

Resource defaultResource = new DefaultResource.Builder().path(thumbnailFilePath).contentType("").build();
AssetInformation defaultAasAssetInformation = aasServiceWithThumbnail.getAssetInformation();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -143,8 +143,14 @@ protected void cleanup() throws ApiException, InterruptedException, Deserializat
adapter.assertNoAdditionalMessages();
GetSubmodelDescriptorsResult result = api.getAllSubmodelDescriptors(null, null);
for (SubmodelDescriptor eachDescriptor : result.getResult()) {
api.deleteSubmodelDescriptorById(eachDescriptor.getId());
assertThatEventWasSend(RegistryEvent.builder().id(eachDescriptor.getId()).type(EventType.SUBMODEL_UNREGISTERED).build());
try {
api.deleteSubmodelDescriptorById(eachDescriptor.getId());
assertThatEventWasSend(RegistryEvent.builder().id(eachDescriptor.getId()).type(EventType.SUBMODEL_UNREGISTERED).build());
} catch (ApiException e) {
if (e.getCode() != NOT_FOUND) {
throw e;
}
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -309,6 +309,11 @@ public InputStream getFileByFilePath(String submodelId, String filePath) {
return decorated.getFileByFilePath(submodelId, filePath);
}

@Override
public String getOriginalFileNameByPath(String submodelId, String idShortPath) {
return decorated.getOriginalFileNameByPath(submodelId, idShortPath);
}

private List<String> getIdAsList(String id) {
return new ArrayList<>(Arrays.asList(id));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,11 @@ public InputStream getFileByFilePath(String submodelId, String filePath) {
return decorated.getFileByFilePath(submodelId, filePath);
}

@Override
public String getOriginalFileNameByPath(String submodelId, String idShortPath) {
return decorated.getOriginalFileNameByPath(submodelId, idShortPath);
}

@Override
public CursorResult<List<Submodel>> getAllSubmodels(String semanticId, PaginationInfo pInfo) {
return decorated.getAllSubmodels(semanticId, pInfo);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,11 @@ public InputStream getFileByFilePath(String submodelId, String filePath) {
return decorated.getFileByFilePath(submodelId, filePath);
}

@Override
public String getOriginalFileNameByPath(String submodelId, String idShortPath) {
return decorated.getOriginalFileNameByPath(submodelId, idShortPath);
}

private void submodelCreated(Submodel submodel, String repoId) {
sendMqttMessage(topicFactory.createCreateSubmodelTopic(repoId), SubmodelSerializer.serializeSubmodel(submodel));
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,30 @@ This means that operations can be delegated to endpoints of the same server as w
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))

As of now, only delegation to HTTP URLs is supported.

## Security defaults for operation delegation

Operation delegation now validates each outbound delegation URI before dispatch.

- Only `http` and `https` URIs are allowed.
- Loopback, private, link-local, and metadata addresses are blocked by default.
- Redirect responses are rejected.
- Explicit allowlists can be configured for approved hybrid deployments.

Example configuration:

```
basyx.submodelrepository.feature.operation.delegation.security.enabled = true
basyx.submodelrepository.feature.operation.delegation.security.denyPrivateTargets = true
basyx.submodelrepository.feature.operation.delegation.security.denyLinkLocalTargets = true
basyx.submodelrepository.feature.operation.delegation.security.denyLoopbackTargets = true
basyx.submodelrepository.feature.operation.delegation.security.denyMetadataTargets = true
basyx.submodelrepository.feature.operation.delegation.security.denyRedirects = true

# Optional explicit overrides for trusted targets
basyx.submodelrepository.feature.operation.delegation.security.allowlist.hosts = localhost,*.trusted.example
basyx.submodelrepository.feature.operation.delegation.security.allowlist.cidrs = 10.42.0.0/16,127.0.0.0/8,::1/128
basyx.submodelrepository.feature.operation.delegation.security.allowlist.ports = 443,8443
```

If an existing setup delegates to localhost or private network endpoints, configure the corresponding allowlist entries explicitly.
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ public class HTTPOperationDelegation implements OperationDelegation {

public static final String INVOCATION_DELEGATION_TYPE = "invocationDelegation";

private WebClient webClient;
private final WebClient webClient;

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

try {
return webClient.post().uri(uri).contentType(MediaType.APPLICATION_JSON).accept(MediaType.APPLICATION_JSON).body(BodyInserters.fromValue(input)).exchangeToMono(response -> {
if (response.statusCode().is3xxRedirection()) {
throw new OperationDelegationException(String.format("Unable to delegate the invocation operation on the URI: '%s' redirects are not allowed", uri));
}

if (response.statusCode().isError()) {
throw new OperationDelegationException(String.format("Unable to delegate the invocation operation on the URI: '%s' the response code is %s", uri, response.statusCode()));
} else {
return response.bodyToMono(OperationVariable[].class);
}
}).block();

} catch (OperationDelegationException e) {
throw e;
} catch (WebClientResponseException e) {
throw new OperationDelegationException(String.format("Exception occurred while invocing operation on the URI: '%s' the error is %s", uri, e.getStackTrace()));
throw new OperationDelegationException(String.format("Exception occurred while invoking operation on the URI: '%s' the error is %s", uri, e.getMessage()));
} catch (Exception e) {
throw new OperationDelegationException(String.format("Exception occurred while invoking operation on the URI: '%s' the error is %s", uri, e.getMessage()));
}

}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
/*******************************************************************************
* Copyright (C) 2026 the Eclipse BaSyx Authors
*
* Permission is hereby granted, free of charge, to any person obtaining
* a copy of this software and associated documentation files (the
* "Software"), to deal in the Software without restriction, including
* without limitation the rights to use, copy, modify, merge, publish,
* distribute, sublicense, and/or sell copies of the Software, and to
* permit persons to whom the Software is furnished to do so, subject to
* the following conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
* MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
* LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
* OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*
* SPDX-License-Identifier: MIT
******************************************************************************/

package org.eclipse.digitaltwin.basyx.submodelrepository.feature.operation.delegation;

import java.util.ArrayList;
import java.util.List;

import org.springframework.boot.context.properties.ConfigurationProperties;

/**
* Security properties for outbound operation delegation requests.
*/
@ConfigurationProperties(prefix = OperationDelegationSubmodelRepositoryFeature.FEATURENAME + ".security")
public class OperationDelegationSecurityProperties {

private boolean enabled = true;
private boolean denyPrivateTargets = true;
private boolean denyLinkLocalTargets = true;
private boolean denyLoopbackTargets = true;
private boolean denyMetadataTargets = true;
private boolean denyRedirects = true;
private Allowlist allowlist = new Allowlist();

public boolean isEnabled() {
return enabled;
}

public void setEnabled(boolean enabled) {
this.enabled = enabled;
}

public boolean isDenyPrivateTargets() {
return denyPrivateTargets;
}

public void setDenyPrivateTargets(boolean denyPrivateTargets) {
this.denyPrivateTargets = denyPrivateTargets;
}

public boolean isDenyLinkLocalTargets() {
return denyLinkLocalTargets;
}

public void setDenyLinkLocalTargets(boolean denyLinkLocalTargets) {
this.denyLinkLocalTargets = denyLinkLocalTargets;
}

public boolean isDenyLoopbackTargets() {
return denyLoopbackTargets;
}

public void setDenyLoopbackTargets(boolean denyLoopbackTargets) {
this.denyLoopbackTargets = denyLoopbackTargets;
}

public boolean isDenyMetadataTargets() {
return denyMetadataTargets;
}

public void setDenyMetadataTargets(boolean denyMetadataTargets) {
this.denyMetadataTargets = denyMetadataTargets;
}

public boolean isDenyRedirects() {
return denyRedirects;
}

public void setDenyRedirects(boolean denyRedirects) {
this.denyRedirects = denyRedirects;
}

public Allowlist getAllowlist() {
return allowlist;
}

public void setAllowlist(Allowlist allowlist) {
this.allowlist = allowlist;
}

public static class Allowlist {
private List<String> hosts = new ArrayList<>();
private List<String> cidrs = new ArrayList<>();
private List<Integer> ports = new ArrayList<>();

public List<String> getHosts() {
return hosts;
}

public void setHosts(List<String> hosts) {
this.hosts = hosts;
}

public List<String> getCidrs() {
return cidrs;
}

public void setCidrs(List<String> cidrs) {
this.cidrs = cidrs;
}

public List<Integer> getPorts() {
return ports;
}

public void setPorts(List<Integer> ports) {
this.ports = ports;
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -184,4 +184,9 @@ public InputStream getFileByFilePath(String submodelId, String filePath) {
return decorated.getFileByFilePath(submodelId, filePath);
}

@Override
public String getOriginalFileNameByPath(String submodelId, String idShortPath) {
return decorated.getOriginalFileNameByPath(submodelId, idShortPath);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,12 @@
import org.eclipse.digitaltwin.basyx.submodelrepository.SubmodelRepository;
import org.springframework.boot.autoconfigure.condition.ConditionalOnExpression;
import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.codec.json.Jackson2JsonDecoder;
import org.springframework.http.codec.json.Jackson2JsonEncoder;
import org.springframework.web.reactive.function.client.ExchangeFilterFunction;
import org.springframework.web.reactive.function.client.ExchangeStrategies;
import org.springframework.web.reactive.function.client.WebClient;

Expand All @@ -44,22 +46,36 @@
*/
@Configuration
@ConditionalOnExpression("${" + OperationDelegationSubmodelRepositoryFeature.FEATURENAME + ".enabled:true}")
@EnableConfigurationProperties(OperationDelegationSecurityProperties.class)
public class OperationDelegationSubmodelRepositoryConfiguration {

@Bean
@ConditionalOnMissingBean
public OperationDelegation getOperationDelegation(ObjectMapper mapper) {
public OperationDelegationTargetValidator operationDelegationTargetValidator(OperationDelegationSecurityProperties securityProperties) {
return new OperationDelegationTargetValidator(securityProperties);
}

@Bean
@ConditionalOnMissingBean
public OperationDelegation getOperationDelegation(ObjectMapper mapper, OperationDelegationTargetValidator targetValidator) {

return new HTTPOperationDelegation(createWebClient(mapper));
return new HTTPOperationDelegation(createWebClient(mapper, targetValidator));
}

private WebClient createWebClient(ObjectMapper mapper) {
private WebClient createWebClient(ObjectMapper mapper, OperationDelegationTargetValidator targetValidator) {
ExchangeStrategies strategies = ExchangeStrategies.builder().codecs(configurer -> {
configurer.defaultCodecs().jackson2JsonEncoder(new Jackson2JsonEncoder(mapper));
configurer.defaultCodecs().jackson2JsonDecoder(new Jackson2JsonDecoder(mapper));
}).build();

return WebClient.builder().exchangeStrategies(strategies).build();
return WebClient.builder().exchangeStrategies(strategies).filter(validateTargetFilter(targetValidator)).build();
}

private ExchangeFilterFunction validateTargetFilter(OperationDelegationTargetValidator targetValidator) {
return (request, next) -> {
targetValidator.validate(request.url());
return next.exchange(request);
};
}

}
Loading