Skip to content

Commit dfb400a

Browse files
committed
Release version 5.3.5
2 parents 8e44d03 + 7dbc03b commit dfb400a

File tree

5 files changed

+192
-37
lines changed

5 files changed

+192
-37
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,8 @@
1+
## [5.3.5] - 2026-04-06
2+
### Changed
3+
- `ProxyRequestFilter` now proxies all HTTP methods generically instead of whitelisting GET/POST/PUT/PATCH/DELETE
4+
- Allow proxying to registered `lapp:Application` endpoints regardless of `ENABLE_LINKED_DATA_PROXY`
5+
16
## [5.3.4] - 2026-04-05
27
### Fixed
38
- Do not append facet well into left-nav when there are no BGP triples

pom.xml

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33

44
<groupId>com.atomgraph</groupId>
55
<artifactId>linkeddatahub</artifactId>
6-
<version>5.3.4</version>
6+
<version>5.3.5</version>
77
<packaging>${packaging.type}</packaging>
88

99
<name>AtomGraph LinkedDataHub</name>
@@ -46,7 +46,7 @@
4646
<url>https://github.com/AtomGraph/LinkedDataHub</url>
4747
<connection>scm:git:git://github.com/AtomGraph/LinkedDataHub.git</connection>
4848
<developerConnection>scm:git:git@github.com:AtomGraph/LinkedDataHub.git</developerConnection>
49-
<tag>linkeddatahub-5.3.4</tag>
49+
<tag>linkeddatahub-5.3.5</tag>
5050
</scm>
5151

5252
<repositories>
@@ -182,6 +182,12 @@
182182
<version>4.13.2</version>
183183
<scope>test</scope>
184184
</dependency>
185+
<dependency>
186+
<groupId>org.mockito</groupId>
187+
<artifactId>mockito-core</artifactId>
188+
<version>5.12.0</version>
189+
<scope>test</scope>
190+
</dependency>
185191
</dependencies>
186192

187193
<build>

src/main/java/com/atomgraph/linkeddatahub/server/filter/request/ProxyRequestFilter.java

Lines changed: 38 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -38,12 +38,12 @@
3838
import java.util.Optional;
3939
import jakarta.annotation.Priority;
4040
import jakarta.inject.Inject;
41-
import jakarta.ws.rs.HttpMethod;
4241
import jakarta.ws.rs.NotAcceptableException;
4342
import jakarta.ws.rs.NotAllowedException;
4443
import jakarta.ws.rs.Priorities;
4544
import jakarta.ws.rs.ProcessingException;
4645
import jakarta.ws.rs.client.Entity;
46+
import jakarta.ws.rs.client.Invocation;
4747
import jakarta.ws.rs.client.WebTarget;
4848
import jakarta.ws.rs.container.ContainerRequestContext;
4949
import jakarta.ws.rs.container.ContainerRequestFilter;
@@ -123,7 +123,9 @@ public void filter(ContainerRequestContext requestContext) throws IOException
123123
return;
124124
}
125125

126-
if (!getSystem().isEnableLinkedDataProxy()) throw new NotAllowedException("Linked Data proxy not enabled");
126+
boolean isRegisteredApp = getSystem().matchApp(targetURI) != null;
127+
if (!isRegisteredApp && !getSystem().isEnableLinkedDataProxy())
128+
throw new NotAllowedException("Linked Data proxy not enabled");
127129
// LNK-009: validate that the target URI is not an internal/private address (SSRF protection)
128130
getSystem().getURLValidator().validate(targetURI);
129131

@@ -141,41 +143,22 @@ else if (agentContext instanceof IDTokenSecurityContext idTokenSecurityContext)
141143
}
142144

143145
List<MediaType> readableMediaTypesList = new ArrayList<>();
144-
readableMediaTypesList.addAll(mediaTypes.getReadable(Model.class));
145-
readableMediaTypesList.addAll(mediaTypes.getReadable(ResultSet.class));
146+
readableMediaTypesList.addAll(getMediaTypes().getReadable(Model.class));
147+
readableMediaTypesList.addAll(getMediaTypes().getReadable(ResultSet.class));
146148
MediaType[] readableMediaTypesArray = readableMediaTypesList.toArray(MediaType[]::new);
147149

148150
if (log.isDebugEnabled()) log.debug("Proxying {} {} → {}", requestContext.getMethod(), requestContext.getUriInfo().getRequestUri(), targetURI);
149151

150152
try
151153
{
152-
Response clientResponse = switch (requestContext.getMethod())
153-
{
154-
case HttpMethod.GET ->
155-
target.request(readableMediaTypesArray)
156-
.header(HttpHeaders.USER_AGENT, GraphStoreClient.USER_AGENT)
157-
.get();
158-
case HttpMethod.POST ->
159-
target.request()
160-
.accept(readableMediaTypesArray)
161-
.header(HttpHeaders.USER_AGENT, GraphStoreClient.USER_AGENT)
162-
.post(Entity.entity(requestContext.getEntityStream(), requestContext.getMediaType()));
163-
case "PATCH" ->
164-
target.request()
165-
.accept(readableMediaTypesArray)
166-
.header(HttpHeaders.USER_AGENT, GraphStoreClient.USER_AGENT)
167-
.method("PATCH", Entity.entity(requestContext.getEntityStream(), requestContext.getMediaType()));
168-
case HttpMethod.PUT ->
169-
target.request()
170-
.accept(readableMediaTypesArray)
171-
.header(HttpHeaders.USER_AGENT, GraphStoreClient.USER_AGENT)
172-
.put(Entity.entity(requestContext.getEntityStream(), requestContext.getMediaType()));
173-
case HttpMethod.DELETE ->
174-
target.request()
175-
.header(HttpHeaders.USER_AGENT, GraphStoreClient.USER_AGENT)
176-
.delete();
177-
default -> throw new NotAllowedException(requestContext.getMethod());
178-
};
154+
Invocation.Builder builder = target.request().
155+
accept(readableMediaTypesArray).
156+
header(HttpHeaders.USER_AGENT, GraphStoreClient.USER_AGENT);
157+
158+
Response clientResponse = requestContext.hasEntity()
159+
? builder.method(requestContext.getMethod(),
160+
Entity.entity(requestContext.getEntityStream(), requestContext.getMediaType()))
161+
: builder.method(requestContext.getMethod());
179162

180163
try (clientResponse)
181164
{
@@ -276,11 +259,11 @@ protected Response getResponse(Response clientResponse, Response.StatusType stat
276259
protected Response getResponse(Model model, Response.StatusType statusType)
277260
{
278261
List<Variant> variants = com.atomgraph.core.model.impl.Response.getVariants(
279-
mediaTypes.getWritable(Model.class),
262+
getMediaTypes().getWritable(Model.class),
280263
getSystem().getSupportedLanguages(),
281264
new ArrayList<>());
282265

283-
return new com.atomgraph.core.model.impl.Response(request,
266+
return new com.atomgraph.core.model.impl.Response(getRequest(),
284267
model,
285268
null,
286269
new EntityTag(Long.toHexString(ModelUtils.hashModel(model))),
@@ -304,11 +287,11 @@ protected Response getResponse(ResultSetRewindable resultSet, Response.StatusTyp
304287
resultSet.reset();
305288

306289
List<Variant> variants = com.atomgraph.core.model.impl.Response.getVariants(
307-
mediaTypes.getWritable(ResultSet.class),
290+
getMediaTypes().getWritable(ResultSet.class),
308291
getSystem().getSupportedLanguages(),
309292
new ArrayList<>());
310293

311-
return new com.atomgraph.core.model.impl.Response(request,
294+
return new com.atomgraph.core.model.impl.Response(getRequest(),
312295
resultSet,
313296
null,
314297
new EntityTag(Long.toHexString(hash)),
@@ -329,4 +312,24 @@ public com.atomgraph.linkeddatahub.Application getSystem()
329312
return system;
330313
}
331314

315+
/**
316+
* Returns the media types registry.
317+
*
318+
* @return media types
319+
*/
320+
public MediaTypes getMediaTypes()
321+
{
322+
return mediaTypes;
323+
}
324+
325+
/**
326+
* Returns the JAX-RS request.
327+
*
328+
* @return request
329+
*/
330+
public Request getRequest()
331+
{
332+
return request;
333+
}
334+
332335
}
Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
/**
2+
* Copyright 2025 Martynas Jusevičius <martynas@atomgraph.com>
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+
* http://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 com.atomgraph.linkeddatahub.server.filter.request;
18+
19+
import com.atomgraph.client.MediaTypes;
20+
import com.atomgraph.client.util.DataManager;
21+
import com.atomgraph.linkeddatahub.server.security.AgentContext;
22+
import com.atomgraph.linkeddatahub.server.util.URLValidator;
23+
import com.atomgraph.linkeddatahub.vocabulary.LAPP;
24+
import jakarta.ws.rs.NotAllowedException;
25+
import jakarta.ws.rs.client.Client;
26+
import jakarta.ws.rs.client.Invocation;
27+
import jakarta.ws.rs.client.WebTarget;
28+
import jakarta.ws.rs.container.ContainerRequestContext;
29+
import jakarta.ws.rs.core.MediaType;
30+
import jakarta.ws.rs.core.MultivaluedHashMap;
31+
import jakarta.ws.rs.core.Request;
32+
import jakarta.ws.rs.core.Response;
33+
import jakarta.ws.rs.core.UriInfo;
34+
import java.io.IOException;
35+
import java.net.URI;
36+
import java.util.List;
37+
import org.apache.jena.query.ResultSet;
38+
import org.apache.jena.rdf.model.Model;
39+
import org.apache.jena.rdf.model.Resource;
40+
import org.junit.Before;
41+
import org.junit.Test;
42+
import org.junit.runner.RunWith;
43+
import org.mockito.InjectMocks;
44+
import org.mockito.Mock;
45+
import org.mockito.junit.MockitoJUnitRunner;
46+
47+
import static org.mockito.ArgumentMatchers.any;
48+
import static org.mockito.ArgumentMatchers.anyString;
49+
import static org.mockito.Mockito.when;
50+
51+
/**
52+
* Unit tests for {@link ProxyRequestFilter}.
53+
*
54+
* @author Martynas Jusevičius {@literal <martynas@atomgraph.com>}
55+
*/
56+
@RunWith(MockitoJUnitRunner.Silent.class)
57+
public class ProxyRequestFilterTest
58+
{
59+
60+
@Mock com.atomgraph.linkeddatahub.Application system;
61+
@Mock MediaTypes mediaTypes;
62+
@Mock Request request;
63+
64+
@InjectMocks ProxyRequestFilter filter;
65+
66+
@Mock ContainerRequestContext requestContext;
67+
@Mock UriInfo uriInfo;
68+
@Mock DataManager dataManager;
69+
@Mock URLValidator urlValidator;
70+
@Mock Client externalClient;
71+
@Mock WebTarget webTarget;
72+
@Mock Invocation.Builder invocationBuilder;
73+
@Mock Response clientResponse;
74+
@Mock Resource registeredApp;
75+
76+
private static final URI ADMIN_URI = URI.create("https://admin.localhost:4443/");
77+
private static final URI EXTERNAL_URI = URI.create("https://example.com/data");
78+
79+
@Before
80+
public void setUp()
81+
{
82+
when(requestContext.getUriInfo()).thenReturn(uriInfo);
83+
when(requestContext.getProperty(LAPP.Application.getURI())).thenReturn(null);
84+
when(requestContext.getProperty(LAPP.Dataset.getURI())).thenReturn(null);
85+
when(system.getDataManager()).thenReturn(dataManager);
86+
when(dataManager.isMapped(anyString())).thenReturn(false);
87+
when(system.isEnableLinkedDataProxy()).thenReturn(false);
88+
}
89+
90+
/**
91+
* When the proxy is disabled, a {@code ?uri=} pointing to an unregistered external URL must be blocked.
92+
*/
93+
@Test(expected = NotAllowedException.class)
94+
public void testUnregisteredUriBlockedWhenProxyDisabled() throws IOException
95+
{
96+
MultivaluedHashMap<String, String> params = new MultivaluedHashMap<>();
97+
params.putSingle("uri", EXTERNAL_URI.toString());
98+
when(uriInfo.getQueryParameters()).thenReturn(params);
99+
100+
filter.filter(requestContext);
101+
}
102+
103+
/**
104+
* When the proxy is disabled, a {@code ?uri=} pointing to a registered {@code lapp:Application}
105+
* must be allowed through — it is a first-party endpoint, not a third-party resource.
106+
*/
107+
@Test
108+
public void testRegisteredAppAllowedWhenProxyDisabled() throws IOException
109+
{
110+
MultivaluedHashMap<String, String> params = new MultivaluedHashMap<>();
111+
params.putSingle("uri", ADMIN_URI.toString());
112+
when(uriInfo.getQueryParameters()).thenReturn(params);
113+
114+
// matchApp() returns a non-null Resource for the admin app (registered lapp:Application)
115+
when(system.matchApp(ADMIN_URI)).thenReturn(registeredApp);
116+
117+
// SSRF validator is a no-op (mock void method)
118+
when(system.getURLValidator()).thenReturn(urlValidator);
119+
120+
// HTTP call chain: GET to the admin app
121+
when(system.getExternalClient()).thenReturn(externalClient);
122+
when(requestContext.getMethod()).thenReturn("GET");
123+
when(requestContext.getProperty(AgentContext.class.getCanonicalName())).thenReturn(null);
124+
when(mediaTypes.getReadable(Model.class)).thenReturn(List.of());
125+
when(mediaTypes.getReadable(ResultSet.class)).thenReturn(List.of());
126+
when(externalClient.target(ADMIN_URI)).thenReturn(webTarget);
127+
when(webTarget.request()).thenReturn(invocationBuilder);
128+
when(invocationBuilder.accept(any(MediaType[].class))).thenReturn(invocationBuilder);
129+
when(invocationBuilder.header(anyString(), any())).thenReturn(invocationBuilder);
130+
when(invocationBuilder.method(anyString())).thenReturn(clientResponse);
131+
132+
// null media type triggers the early-return path in getResponse(Response)
133+
when(clientResponse.getHeaders()).thenReturn(new MultivaluedHashMap<>());
134+
when(clientResponse.getMediaType()).thenReturn(null);
135+
when(clientResponse.getStatus()).thenReturn(200);
136+
137+
filter.filter(requestContext); // must not throw NotAllowedException
138+
}
139+
140+
}
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
mock-maker-subclass

0 commit comments

Comments
 (0)