Skip to content

Commit a1e3e57

Browse files
namedgraphclaude
andcommitted
Allow proxying to registered lapp:Application endpoints regardless of ENABLE_LINKED_DATA_PROXY
When ENABLE_LINKED_DATA_PROXY=false, requests to registered lapp:Application base URIs (e.g. demo.linkeddatahub.com from linkeddatahub.com) were incorrectly blocked. These are first-party endpoints, not third-party proxy targets. ProxyRequestFilter now calls matchApp(targetURI) before the proxy-enabled check; if the target's origin matches a registered application, the check is skipped. SSRF validation is preserved for all requests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 897383e commit a1e3e57

File tree

4 files changed

+149
-1
lines changed

4 files changed

+149
-1
lines changed

pom.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,139 @@
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(any(MediaType[].class))).thenReturn(invocationBuilder);
128+
when(invocationBuilder.header(anyString(), any())).thenReturn(invocationBuilder);
129+
when(invocationBuilder.get()).thenReturn(clientResponse);
130+
131+
// null media type triggers the early-return path in getResponse(Response)
132+
when(clientResponse.getHeaders()).thenReturn(new MultivaluedHashMap<>());
133+
when(clientResponse.getMediaType()).thenReturn(null);
134+
when(clientResponse.getStatus()).thenReturn(200);
135+
136+
filter.filter(requestContext); // must not throw NotAllowedException
137+
}
138+
139+
}
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)